mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Add import data feature
This commit is contained in:
parent
4898cd82ac
commit
8ad0b20d3d
46 changed files with 4356 additions and 64 deletions
283
.superdesign/design_iterations/trip_page_1.html
Normal file
283
.superdesign/design_iterations/trip_page_1.html
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>European Grand Tour - Trip Details</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 text-black min-h-screen">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Trip Header -->
|
||||||
|
<header class="mb-8">
|
||||||
|
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-light tracking-tight text-black mb-4">
|
||||||
|
European Grand Tour
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-gray-600 max-w-2xl">
|
||||||
|
A 21-day journey through the heart of Europe, discovering historic cities, stunning landscapes, and rich cultural heritage.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<!-- Map Area - Hero Element -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="h-96 lg:h-[500px] bg-gray-100 relative flex items-center justify-center">
|
||||||
|
<!-- Map Placeholder -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-16 h-16 bg-gray-300 rounded-full mx-auto mb-4"></div>
|
||||||
|
<p class="text-gray-500 text-sm">Interactive Map</p>
|
||||||
|
<p class="text-gray-400 text-xs mt-1">Route visualization would appear here</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Route indicators -->
|
||||||
|
<div class="absolute top-4 left-4">
|
||||||
|
<div class="bg-black text-white px-3 py-1 rounded-full text-xs font-medium">
|
||||||
|
Start: Amsterdam
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-4 right-4">
|
||||||
|
<div class="bg-gray-800 text-white px-3 py-1 rounded-full text-xs font-medium">
|
||||||
|
End: Rome
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Trip Statistics -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-medium text-black mb-6">Trip Statistics</h2>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-b border-gray-100 pb-4">
|
||||||
|
<div class="text-2xl font-light text-black">3,247 km</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">Total Distance</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-b border-gray-100 pb-4">
|
||||||
|
<div class="text-2xl font-light text-black">21 days</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">Duration</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-light text-black">7 countries</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">Countries Visited</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Countries List -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 class="text-lg font-medium text-black mb-4">Countries Visited</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-900">Netherlands</span>
|
||||||
|
<span class="text-xs text-gray-500">3 days</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-900">Germany</span>
|
||||||
|
<span class="text-xs text-gray-500">4 days</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-900">Austria</span>
|
||||||
|
<span class="text-xs text-gray-500">2 days</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-900">Switzerland</span>
|
||||||
|
<span class="text-xs text-gray-500">3 days</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-900">France</span>
|
||||||
|
<span class="text-xs text-gray-500">4 days</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-900">Monaco</span>
|
||||||
|
<span class="text-xs text-gray-500">1 day</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-900">Italy</span>
|
||||||
|
<span class="text-xs text-gray-500">4 days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos Section -->
|
||||||
|
<section class="mt-16">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-light text-black">Trip Photos</h2>
|
||||||
|
<div class="text-sm text-gray-600">147 photos</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo Grid -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||||
|
<!-- Photo placeholders -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="aspect-square bg-gray-100 relative">
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs text-gray-600">Amsterdam Canal</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="aspect-square bg-gray-100 relative">
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs text-gray-600">Berlin Wall</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="aspect-square bg-gray-100 relative">
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs text-gray-600">Alpine Vista</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="aspect-square bg-gray-100 relative">
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs text-gray-600">Swiss Mountains</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="aspect-square bg-gray-100 relative">
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs text-gray-600">Eiffel Tower</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="aspect-square bg-gray-100 relative">
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs text-gray-600">Monaco Harbor</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="aspect-square bg-gray-100 relative">
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs text-gray-600">Colosseum</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="aspect-square bg-gray-100 relative">
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs text-gray-600">Roman Forum</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load more indicator -->
|
||||||
|
<div class="col-span-2 sm:col-span-3 lg:col-span-4 xl:col-span-6 mt-8">
|
||||||
|
<button class="w-full py-3 px-4 bg-white border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors text-sm font-medium">
|
||||||
|
Load More Photos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Trip Timeline (Additional Context) -->
|
||||||
|
<section class="mt-16">
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-light text-black mb-8">Trip Timeline</h2>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="w-2 h-2 bg-black rounded-full mt-2 flex-shrink-0"></div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-black">Day 1-3: Amsterdam, Netherlands</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">Explored canals, visited museums, experienced local culture</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-black">Day 4-7: Berlin & Munich, Germany</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">Historical sites, traditional cuisine, alpine preparation</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-black">Day 8-9: Salzburg, Austria</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">Mozart's birthplace, stunning architecture</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-black">Day 10-12: Zurich & Alps, Switzerland</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">Mountain adventures, pristine lakes, scenic drives</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-black">Day 13-16: Paris & Lyon, France</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">Art, cuisine, romance, and French countryside</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-black">Day 17: Monaco</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">Luxury, casinos, and Mediterranean coastline</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-black">Day 18-21: Rome, Italy</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">Ancient history, incredible food, perfect ending</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
238
.superdesign/design_iterations/trip_page_2.html
Normal file
238
.superdesign/design_iterations/trip_page_2.html
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Asian Adventure - Trip Details</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-white text-black min-h-screen">
|
||||||
|
<!-- Main Container -->
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
|
||||||
|
<!-- Trip Header -->
|
||||||
|
<header class="mb-8">
|
||||||
|
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-light tracking-tight mb-2">
|
||||||
|
Asian Adventure
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-gray-600 font-light">
|
||||||
|
A journey through Southeast Asia's cultural treasures
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
|
|
||||||
|
<!-- Map Section (Takes up 3/4 on desktop) -->
|
||||||
|
<div class="lg:col-span-3 order-2 lg:order-1">
|
||||||
|
<div class="bg-gray-100 border border-gray-200 rounded-lg aspect-[4/3] flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-16 h-16 bg-gray-300 rounded-full mx-auto mb-4"></div>
|
||||||
|
<p class="text-gray-500 font-light">Interactive Map</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-2">Route visualization</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar (Stats and Info) -->
|
||||||
|
<div class="lg:col-span-1 order-1 lg:order-2 space-y-8">
|
||||||
|
|
||||||
|
<!-- Trip Stats -->
|
||||||
|
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6">
|
||||||
|
<h2 class="text-lg font-medium mb-6 tracking-tight">Trip Statistics</h2>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-light text-black mb-1">2,847 km</div>
|
||||||
|
<div class="text-sm text-gray-600">Total Distance</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-light text-black mb-1">18 days</div>
|
||||||
|
<div class="text-sm text-gray-600">Duration</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-light text-black mb-1">5 countries</div>
|
||||||
|
<div class="text-sm text-gray-600">Countries Visited</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Countries List -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium mb-4 tracking-tight">Countries</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<span class="font-light">Thailand</span>
|
||||||
|
<span class="text-sm text-gray-500">6 days</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<span class="font-light">Vietnam</span>
|
||||||
|
<span class="text-sm text-gray-500">4 days</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<span class="font-light">Cambodia</span>
|
||||||
|
<span class="text-sm text-gray-500">3 days</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<span class="font-light">Laos</span>
|
||||||
|
<span class="text-sm text-gray-500">3 days</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<span class="font-light">Myanmar</span>
|
||||||
|
<span class="text-sm text-gray-500">2 days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium mb-4 tracking-tight">Highlights</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-2 h-2 bg-black rounded-full"></div>
|
||||||
|
<span class="text-sm font-light">12 temples visited</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-2 h-2 bg-black rounded-full"></div>
|
||||||
|
<span class="text-sm font-light">4 cooking classes</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-2 h-2 bg-black rounded-full"></div>
|
||||||
|
<span class="text-sm font-light">8 markets explored</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-2 h-2 bg-black rounded-full"></div>
|
||||||
|
<span class="text-sm font-light">3 boat rides</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos Section -->
|
||||||
|
<section class="mt-16">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-light tracking-tight">Trip Photos</h2>
|
||||||
|
<span class="text-sm text-gray-500 font-light">247 photos</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo Grid -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||||
|
<!-- Photo placeholders with varying aspect ratios -->
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-[4/3] flex items-center justify-center">
|
||||||
|
<div class="w-8 h-6 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-[3/4] flex items-center justify-center">
|
||||||
|
<div class="w-6 h-8 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-[4/3] flex items-center justify-center">
|
||||||
|
<div class="w-8 h-6 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-[3/4] flex items-center justify-center">
|
||||||
|
<div class="w-6 h-8 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-[4/3] flex items-center justify-center">
|
||||||
|
<div class="w-8 h-6 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 rounded-lg aspect-[3/4] flex items-center justify-center">
|
||||||
|
<div class="w-6 h-8 bg-gray-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load More Button -->
|
||||||
|
<div class="flex justify-center mt-8">
|
||||||
|
<button class="px-8 py-3 border border-gray-300 rounded-lg text-sm font-light hover:bg-gray-50 transition-colors">
|
||||||
|
Load More Photos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Trip Timeline (Additional Section) -->
|
||||||
|
<section class="mt-16 border-t border-gray-200 pt-16">
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-light tracking-tight mb-8">Trip Timeline</h2>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||||
|
<div class="flex-shrink-0 sm:w-24">
|
||||||
|
<span class="text-sm text-gray-500 font-light">Day 1-6</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h3 class="font-medium mb-2">Bangkok & Northern Thailand</h3>
|
||||||
|
<p class="text-gray-600 font-light text-sm leading-relaxed">
|
||||||
|
Explored the bustling streets of Bangkok, visited ancient temples, and trekked through the mountains of Chiang Mai.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||||
|
<div class="flex-shrink-0 sm:w-24">
|
||||||
|
<span class="text-sm text-gray-500 font-light">Day 7-10</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h3 class="font-medium mb-2">Ho Chi Minh City & Hanoi</h3>
|
||||||
|
<p class="text-gray-600 font-light text-sm leading-relaxed">
|
||||||
|
Discovered Vietnamese culture, cuisine, and history across the country's two major cities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||||
|
<div class="flex-shrink-0 sm:w-24">
|
||||||
|
<span class="text-sm text-gray-500 font-light">Day 11-13</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h3 class="font-medium mb-2">Siem Reap, Cambodia</h3>
|
||||||
|
<p class="text-gray-600 font-light text-sm leading-relaxed">
|
||||||
|
Marveled at the ancient temples of Angkor Wat and experienced traditional Khmer culture.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||||
|
<div class="flex-shrink-0 sm:w-24">
|
||||||
|
<span class="text-sm text-gray-500 font-light">Day 14-16</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h3 class="font-medium mb-2">Luang Prabang, Laos</h3>
|
||||||
|
<p class="text-gray-600 font-light text-sm leading-relaxed">
|
||||||
|
Experienced the peaceful atmosphere of this UNESCO World Heritage city along the Mekong River.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||||
|
<div class="flex-shrink-0 sm:w-24">
|
||||||
|
<span class="text-sm text-gray-500 font-light">Day 17-18</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h3 class="font-medium mb-2">Yangon, Myanmar</h3>
|
||||||
|
<p class="text-gray-600 font-light text-sm leading-relaxed">
|
||||||
|
Concluded the journey with visits to golden pagodas and local markets in Myanmar's largest city.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
316
.superdesign/design_iterations/trip_page_3.html
Normal file
316
.superdesign/design_iterations/trip_page_3.html
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Coast to Coast Adventure - Trip Details</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
.map-placeholder {
|
||||||
|
background: linear-gradient(45deg, #f3f4f6 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #f3f4f6 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #f3f4f6 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #f3f4f6 75%);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-placeholder {
|
||||||
|
background: linear-gradient(135deg, #e5e7eb 0%, #f9fafb 50%, #e5e7eb 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-white text-black font-sans antialiased">
|
||||||
|
<!-- Main Container -->
|
||||||
|
<div class="min-h-screen p-4 md:p-8">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
<!-- Trip Header -->
|
||||||
|
<header class="mb-8">
|
||||||
|
<h1 class="text-4xl md:text-5xl lg:text-6xl font-light tracking-tight mb-2">
|
||||||
|
Coast to Coast Adventure
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg md:text-xl text-gray-600 font-light">
|
||||||
|
New York City to San Francisco • October 2024
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Grid Layout -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
|
|
||||||
|
<!-- Map Section -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div class="p-6 border-b border-gray-100">
|
||||||
|
<h2 class="text-2xl font-light mb-2">Route Overview</h2>
|
||||||
|
<p class="text-gray-600">Interactive journey across America</p>
|
||||||
|
</div>
|
||||||
|
<div class="map-placeholder h-96 md:h-[500px] flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-16 h-16 mx-auto mb-4 bg-gray-300 rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 font-light">Interactive Map</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<div>
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-light mb-6">Trip Statistics</h2>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Distance -->
|
||||||
|
<div class="stat-card bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 mb-1">Total Distance</p>
|
||||||
|
<p class="text-3xl font-light">2,908 mi</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-white rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration -->
|
||||||
|
<div class="stat-card bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 mb-1">Duration</p>
|
||||||
|
<p class="text-3xl font-light">14 days</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-white rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Countries -->
|
||||||
|
<div class="stat-card bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 mb-1">States Visited</p>
|
||||||
|
<p class="text-3xl font-light">12</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-white rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- State List -->
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-100">
|
||||||
|
<h3 class="text-lg font-light mb-4">States Crossed</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>New York</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>Pennsylvania</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>Ohio</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>Indiana</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>Illinois</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>Iowa</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>Nebraska</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>Colorado</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>Utah</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>Nevada</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>California</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos Section -->
|
||||||
|
<div>
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-light mb-6">Trip Highlights</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<!-- Featured Photo -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<div class="photo-placeholder h-48 rounded-lg flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-12 h-12 mx-auto mb-2 bg-gray-400 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500">Golden Gate Bridge</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Small Photos -->
|
||||||
|
<div class="photo-placeholder h-24 rounded-lg flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-8 h-8 mx-auto mb-1 bg-gray-400 rounded flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">Chicago Skyline</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="photo-placeholder h-24 rounded-lg flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-8 h-8 mx-auto mb-1 bg-gray-400 rounded flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">Rocky Mountains</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="photo-placeholder h-24 rounded-lg flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-8 h-8 mx-auto mb-1 bg-gray-400 rounded flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">Monument Valley</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="photo-placeholder h-24 rounded-lg flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-8 h-8 mx-auto mb-1 bg-gray-400 rounded flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">Route 66</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo Counter -->
|
||||||
|
<div class="text-center">
|
||||||
|
<button class="text-sm text-gray-600 hover:text-black transition-colors duration-200 border border-gray-200 px-4 py-2 rounded-lg hover:border-gray-300">
|
||||||
|
View all 247 photos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Trip Details -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<!-- Key Stops -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<h3 class="text-xl font-light mb-4">Key Stops</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<span class="text-sm">Times Square, NYC</span>
|
||||||
|
<span class="text-sm text-gray-500">Day 1</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<span class="text-sm">Millennium Park, Chicago</span>
|
||||||
|
<span class="text-sm text-gray-500">Day 4</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<span class="text-sm">Rocky Mountain National Park</span>
|
||||||
|
<span class="text-sm text-gray-500">Day 8</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<span class="text-sm">Arches National Park</span>
|
||||||
|
<span class="text-sm text-gray-500">Day 10</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<span class="text-sm">Golden Gate Bridge, SF</span>
|
||||||
|
<span class="text-sm text-gray-500">Day 14</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weather Summary -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<h3 class="text-xl font-light mb-4">Weather Summary</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm">Average Temperature</span>
|
||||||
|
<span class="text-sm font-medium">68°F</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm">Sunny Days</span>
|
||||||
|
<span class="text-sm font-medium">11 of 14</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm">Rain Days</span>
|
||||||
|
<span class="text-sm font-medium">2 of 14</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm">Best Weather</span>
|
||||||
|
<span class="text-sm font-medium">Utah, Nevada</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trip Notes -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<h3 class="text-xl font-light mb-4">Trip Notes</h3>
|
||||||
|
<div class="space-y-3 text-sm text-gray-700">
|
||||||
|
<p>Perfect timing for fall foliage in the Midwest. Colorado mountains were breathtaking with early snow caps.</p>
|
||||||
|
<p>Route 66 sections in Illinois and Missouri provided authentic American road trip experience.</p>
|
||||||
|
<p>Utah's landscape diversity exceeded expectations - from desert to mountain passes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
189
.superdesign/design_iterations/trip_page_3_1.html
Normal file
189
.superdesign/design_iterations/trip_page_3_1.html
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Coast to Coast Adventure - Trip Details</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
.map-placeholder {
|
||||||
|
background: linear-gradient(45deg, #f3f4f6 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #f3f4f6 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #f3f4f6 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #f3f4f6 75%);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-placeholder {
|
||||||
|
background: linear-gradient(135deg, #e5e7eb 0%, #f9fafb 50%, #e5e7eb 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 text-black font-sans antialiased">
|
||||||
|
<!-- Main Container -->
|
||||||
|
<div class="min-h-screen p-4">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
|
||||||
|
<!-- Compact Header -->
|
||||||
|
<header class="mb-6 bg-white rounded-lg p-4 border border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-light tracking-tight">Coast to Coast Adventure</h1>
|
||||||
|
<p class="text-sm text-gray-600">NYC → SF • Oct 2024</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right text-sm">
|
||||||
|
<div class="text-lg font-light">2,908 mi</div>
|
||||||
|
<div class="text-gray-600">14 days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Layout -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
|
||||||
|
<!-- Map Section -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div class="map-placeholder h-64 lg:h-80 flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-12 h-12 mx-auto mb-2 bg-gray-300 rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">Route Map</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compact Data Panel -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 class="text-sm font-medium mb-3 text-gray-800">Trip Stats</h3>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">Distance</span>
|
||||||
|
<span class="font-medium">2,908 mi</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">Duration</span>
|
||||||
|
<span class="font-medium">14 days</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">States</span>
|
||||||
|
<span class="font-medium">12</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">Photos</span>
|
||||||
|
<span class="font-medium">247</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compact States List -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 class="text-sm font-medium mb-3 text-gray-800">Route</h3>
|
||||||
|
<div class="text-xs text-gray-600 leading-relaxed">
|
||||||
|
NY → PA → OH → IN → IL → IA → NE → CO → UT → NV → CA
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compact Photos -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 class="text-sm font-medium mb-3 text-gray-800">Highlights</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="photo-placeholder h-16 rounded flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="photo-placeholder h-16 rounded flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="photo-placeholder h-16 rounded flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="photo-placeholder h-16 rounded flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compact Additional Details -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<!-- Key Stops -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 class="text-sm font-medium mb-3 text-gray-800">Key Stops</h3>
|
||||||
|
<div class="space-y-1 text-xs">
|
||||||
|
<div class="flex justify-between py-1">
|
||||||
|
<span>Times Square</span>
|
||||||
|
<span class="text-gray-500">Day 1</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1">
|
||||||
|
<span>Chicago</span>
|
||||||
|
<span class="text-gray-500">Day 4</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1">
|
||||||
|
<span>Rocky Mountains</span>
|
||||||
|
<span class="text-gray-500">Day 8</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1">
|
||||||
|
<span>Arches NP</span>
|
||||||
|
<span class="text-gray-500">Day 10</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1">
|
||||||
|
<span>Golden Gate</span>
|
||||||
|
<span class="text-gray-500">Day 14</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weather -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 class="text-sm font-medium mb-3 text-gray-800">Weather</h3>
|
||||||
|
<div class="space-y-1 text-xs">
|
||||||
|
<div class="flex justify-between py-1">
|
||||||
|
<span>Avg Temp</span>
|
||||||
|
<span class="font-medium">68°F</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1">
|
||||||
|
<span>Sunny Days</span>
|
||||||
|
<span class="font-medium">11/14</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1">
|
||||||
|
<span>Rain Days</span>
|
||||||
|
<span class="font-medium">2/14</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1">
|
||||||
|
<span>Best</span>
|
||||||
|
<span class="font-medium">Utah, Nevada</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Notes -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 class="text-sm font-medium mb-3 text-gray-800">Notes</h3>
|
||||||
|
<div class="text-xs text-gray-700 space-y-2">
|
||||||
|
<p>Fall foliage in Midwest was perfect timing.</p>
|
||||||
|
<p>Route 66 sections provided authentic experience.</p>
|
||||||
|
<p>Utah landscape diversity exceeded expectations.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
- [x] All your stats
|
- [x] All your stats
|
||||||
|
|
||||||
- [ ] In the User Settings, you can now import your user data from a zip file. It will import all the data from the zip file, listed above. It will also start stats recalculation.
|
- [ ] In the User Settings, you can now import your user data from a zip file. It will import all the data from the zip file, listed above. It will also start stats recalculation.
|
||||||
|
- [ ] User can select to override settings or not.
|
||||||
|
|
||||||
- Export file size is now displayed in the exports and imports lists.
|
- Export file size is now displayed in the exports and imports lists.
|
||||||
|
|
||||||
|
|
@ -27,6 +28,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
- Oj is now being used for JSON serialization.
|
- Oj is now being used for JSON serialization.
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Email links now use the SMTP domain if set. #1469
|
||||||
|
|
||||||
# 0.28.1 - 2025-06-11
|
# 0.28.1 - 2025-06-11
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
class Settings::UsersController < ApplicationController
|
class Settings::UsersController < ApplicationController
|
||||||
before_action :authenticate_self_hosted!
|
before_action :authenticate_self_hosted!
|
||||||
before_action :authenticate_admin!
|
before_action :authenticate_admin!, except: [:export, :import]
|
||||||
|
before_action :authenticate_user!, only: [:export, :import]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@users = User.order(created_at: :desc)
|
@users = User.order(created_at: :desc)
|
||||||
|
|
@ -53,7 +54,40 @@ class Settings::UsersController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def import
|
def import
|
||||||
|
unless params[:archive].present?
|
||||||
|
redirect_to edit_user_registration_path, alert: 'Please select a ZIP archive to import.'
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
archive_file = params[:archive]
|
||||||
|
|
||||||
|
# Validate file type
|
||||||
|
unless archive_file.content_type == 'application/zip' ||
|
||||||
|
archive_file.content_type == 'application/x-zip-compressed' ||
|
||||||
|
File.extname(archive_file.original_filename).downcase == '.zip'
|
||||||
|
redirect_to edit_user_registration_path, alert: 'Please upload a valid ZIP file.'
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create Import record for user data archive
|
||||||
|
import = current_user.imports.build(
|
||||||
|
name: archive_file.original_filename,
|
||||||
|
source: :user_data_archive
|
||||||
|
)
|
||||||
|
|
||||||
|
import.file.attach(archive_file)
|
||||||
|
|
||||||
|
if import.save
|
||||||
|
redirect_to edit_user_registration_path,
|
||||||
|
notice: 'Your data import has been started. You will receive a notification when it completes.'
|
||||||
|
else
|
||||||
|
redirect_to edit_user_registration_path,
|
||||||
|
alert: 'Failed to start import. Please try again.'
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
ExceptionReporter.call(e, 'User data import failed to start')
|
||||||
|
redirect_to edit_user_registration_path,
|
||||||
|
alert: 'An error occurred while starting the import. Please try again.'
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
class Users::ExportDataJob < ApplicationJob
|
class Users::ExportDataJob < ApplicationJob
|
||||||
queue_as :exports
|
queue_as :exports
|
||||||
|
|
||||||
|
sidekiq_options retry: false
|
||||||
|
|
||||||
def perform(user_id)
|
def perform(user_id)
|
||||||
user = User.find(user_id)
|
user = User.find(user_id)
|
||||||
|
|
||||||
|
|
|
||||||
64
app/jobs/users/import_data_job.rb
Normal file
64
app/jobs/users/import_data_job.rb
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ImportDataJob < ApplicationJob
|
||||||
|
queue_as :imports
|
||||||
|
|
||||||
|
sidekiq_options retry: false
|
||||||
|
|
||||||
|
def perform(import_id)
|
||||||
|
import = Import.find(import_id)
|
||||||
|
user = import.user
|
||||||
|
|
||||||
|
# Download the archive file to a temporary location
|
||||||
|
archive_path = download_import_archive(import)
|
||||||
|
|
||||||
|
# Validate that the archive file exists
|
||||||
|
unless File.exist?(archive_path)
|
||||||
|
raise StandardError, "Archive file not found: #{archive_path}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Perform the import
|
||||||
|
import_stats = Users::ImportData.new(user, archive_path).import
|
||||||
|
|
||||||
|
Rails.logger.info "Import completed successfully for user #{user.email}: #{import_stats}"
|
||||||
|
rescue StandardError => e
|
||||||
|
user_id = user&.id || import&.user_id || "unknown"
|
||||||
|
ExceptionReporter.call(e, "Import job failed for user #{user_id}")
|
||||||
|
|
||||||
|
# Create failure notification if user is available
|
||||||
|
if user
|
||||||
|
::Notifications::Create.new(
|
||||||
|
user: user,
|
||||||
|
title: 'Data import failed',
|
||||||
|
content: "Your data import failed with error: #{e.message}. Please check the archive format and try again.",
|
||||||
|
kind: :error
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
|
||||||
|
raise e
|
||||||
|
ensure
|
||||||
|
# Clean up the uploaded archive file if it exists
|
||||||
|
if archive_path && File.exist?(archive_path)
|
||||||
|
File.delete(archive_path)
|
||||||
|
Rails.logger.info "Cleaned up archive file: #{archive_path}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def download_import_archive(import)
|
||||||
|
require 'tmpdir'
|
||||||
|
|
||||||
|
timestamp = Time.current.to_i
|
||||||
|
filename = "user_import_#{import.user_id}_#{import.id}_#{timestamp}.zip"
|
||||||
|
temp_path = File.join(Dir.tmpdir, filename)
|
||||||
|
|
||||||
|
File.open(temp_path, 'wb') do |file_handle|
|
||||||
|
import.file.download do |chunk|
|
||||||
|
file_handle.write(chunk)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
temp_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -11,13 +11,24 @@ class Import < ApplicationRecord
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
validates :name, presence: true, uniqueness: { scope: :user_id }
|
||||||
|
|
||||||
|
enum :status, { created: 0, processing: 1, completed: 2, failed: 3 }
|
||||||
|
|
||||||
enum :source, {
|
enum :source, {
|
||||||
google_semantic_history: 0, owntracks: 1, google_records: 2,
|
google_semantic_history: 0, owntracks: 1, google_records: 2,
|
||||||
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7
|
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7,
|
||||||
|
user_data_archive: 8
|
||||||
}
|
}
|
||||||
|
|
||||||
def process!
|
def process!
|
||||||
Imports::Create.new(user, self).call
|
if user_data_archive?
|
||||||
|
process_user_data_archive!
|
||||||
|
else
|
||||||
|
Imports::Create.new(user, self).call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_user_data_archive!
|
||||||
|
Users::ImportDataJob.perform_later(id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def reverse_geocoded_points_count
|
def reverse_geocoded_points_count
|
||||||
|
|
@ -39,7 +50,7 @@ class Import < ApplicationRecord
|
||||||
file.attach(io: raw_file, filename: name, content_type: 'application/json')
|
file.attach(io: raw_file, filename: name, content_type: 'application/json')
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def remove_attached_file
|
def remove_attached_file
|
||||||
file.purge_later
|
file.purge_later
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ class Point < ApplicationRecord
|
||||||
timestamp.to_s,
|
timestamp.to_s,
|
||||||
velocity.to_s,
|
velocity.to_s,
|
||||||
id.to_s,
|
id.to_s,
|
||||||
country.to_s
|
country_name.to_s
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -87,4 +87,9 @@ class Point < ApplicationRecord
|
||||||
self.country_id = found_in_country&.id
|
self.country_id = found_in_country&.id
|
||||||
save! if changed?
|
save! if changed?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def country_name
|
||||||
|
# Safely get country name from association or attribute
|
||||||
|
self.country&.name || read_attribute(:country) || ''
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ExceptionReporter
|
class ExceptionReporter
|
||||||
def self.call(exception)
|
def self.call(exception, human_message = nil)
|
||||||
return unless DawarichSettings.self_hosted?
|
return unless DawarichSettings.self_hosted?
|
||||||
|
|
||||||
Rails.logger.error "Exception: #{exception.message}"
|
Rails.logger.error "#{human_message}: #{exception.message}"
|
||||||
|
|
||||||
Sentry.capture_exception(exception)
|
Sentry.capture_exception(exception)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,19 @@ class Imports::Create
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
|
import.update!(status: :processing)
|
||||||
|
|
||||||
importer(import.source).new(import, user.id).call
|
importer(import.source).new(import, user.id).call
|
||||||
|
|
||||||
schedule_stats_creating(user.id)
|
schedule_stats_creating(user.id)
|
||||||
schedule_visit_suggesting(user.id, import)
|
schedule_visit_suggesting(user.id, import)
|
||||||
update_import_points_count(import)
|
update_import_points_count(import)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
|
import.update!(status: :failed)
|
||||||
|
|
||||||
create_import_failed_notification(import, user, e)
|
create_import_failed_notification(import, user, e)
|
||||||
|
ensure
|
||||||
|
import.update!(status: :completed) if import.completed?
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
||||||
18
app/services/notifications.rb
Normal file
18
app/services/notifications.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Notifications
|
||||||
|
class Create
|
||||||
|
attr_reader :user, :kind, :title, :content
|
||||||
|
|
||||||
|
def initialize(user:, kind:, title:, content:)
|
||||||
|
@user = user
|
||||||
|
@kind = kind
|
||||||
|
@title = title
|
||||||
|
@content = content
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
Notification.create!(user:, kind:, title:, content:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class Notifications::Create
|
|
||||||
attr_reader :user, :kind, :title, :content
|
|
||||||
|
|
||||||
def initialize(user:, kind:, title:, content:)
|
|
||||||
@user = user
|
|
||||||
@kind = kind
|
|
||||||
@title = title
|
|
||||||
@content = content
|
|
||||||
end
|
|
||||||
|
|
||||||
def call
|
|
||||||
Notification.create!(user:, kind:, title:, content:)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -6,6 +6,17 @@ require 'zip'
|
||||||
#
|
#
|
||||||
# Output JSON Structure Example:
|
# Output JSON Structure Example:
|
||||||
# {
|
# {
|
||||||
|
# "counts": {
|
||||||
|
# "areas": 5,
|
||||||
|
# "imports": 12,
|
||||||
|
# "exports": 3,
|
||||||
|
# "trips": 8,
|
||||||
|
# "stats": 24,
|
||||||
|
# "notifications": 10,
|
||||||
|
# "points": 15000,
|
||||||
|
# "visits": 45,
|
||||||
|
# "places": 20
|
||||||
|
# },
|
||||||
# "settings": {
|
# "settings": {
|
||||||
# "distance_unit": "km",
|
# "distance_unit": "km",
|
||||||
# "timezone": "UTC",
|
# "timezone": "UTC",
|
||||||
|
|
@ -227,7 +238,11 @@ class Users::ExportData
|
||||||
|
|
||||||
# Stream JSON writing instead of building in memory
|
# Stream JSON writing instead of building in memory
|
||||||
File.open(json_file_path, 'w') do |file|
|
File.open(json_file_path, 'w') do |file|
|
||||||
file.write('{"settings":')
|
# Start JSON and add counts summary
|
||||||
|
file.write('{"counts":')
|
||||||
|
file.write(calculate_entity_counts.to_json)
|
||||||
|
|
||||||
|
file.write(',"settings":')
|
||||||
file.write(user.safe_settings.settings.to_json)
|
file.write(user.safe_settings.settings.to_json)
|
||||||
|
|
||||||
file.write(',"areas":')
|
file.write(',"areas":')
|
||||||
|
|
@ -281,7 +296,7 @@ class Users::ExportData
|
||||||
# Mark export as failed if an error occurs
|
# Mark export as failed if an error occurs
|
||||||
export_record.update!(status: :failed) if export_record
|
export_record.update!(status: :failed) if export_record
|
||||||
|
|
||||||
ExceptionReporter.call(e)
|
ExceptionReporter.call(e, 'Export failed')
|
||||||
|
|
||||||
raise e
|
raise e
|
||||||
ensure
|
ensure
|
||||||
|
|
@ -302,30 +317,44 @@ class Users::ExportData
|
||||||
@files_directory
|
@files_directory
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def calculate_entity_counts
|
||||||
|
Rails.logger.info "Calculating entity counts for export"
|
||||||
|
|
||||||
|
counts = {
|
||||||
|
areas: user.areas.count,
|
||||||
|
imports: user.imports.count,
|
||||||
|
exports: user.exports.count,
|
||||||
|
trips: user.trips.count,
|
||||||
|
stats: user.stats.count,
|
||||||
|
notifications: user.notifications.count,
|
||||||
|
points: user.tracked_points.count,
|
||||||
|
visits: user.visits.count,
|
||||||
|
places: user.places.count
|
||||||
|
}
|
||||||
|
|
||||||
|
Rails.logger.info "Entity counts: #{counts}"
|
||||||
|
counts
|
||||||
|
end
|
||||||
|
|
||||||
def create_zip_archive(export_directory, zip_file_path)
|
def create_zip_archive(export_directory, zip_file_path)
|
||||||
|
# Set global compression level for better file size reduction
|
||||||
|
original_compression = Zip.default_compression
|
||||||
|
Zip.default_compression = Zlib::BEST_COMPRESSION
|
||||||
|
|
||||||
# Create zip archive with optimized compression
|
# Create zip archive with optimized compression
|
||||||
Zip::File.open(zip_file_path, Zip::File::CREATE) do |zipfile|
|
Zip::File.open(zip_file_path, Zip::File::CREATE) do |zipfile|
|
||||||
# Set higher compression for better file size reduction
|
|
||||||
zipfile.default_compression = Zip::Entry::DEFLATED
|
|
||||||
zipfile.default_compression_level = 9 # Maximum compression
|
|
||||||
|
|
||||||
Dir.glob(export_directory.join('**', '*')).each do |file|
|
Dir.glob(export_directory.join('**', '*')).each do |file|
|
||||||
next if File.directory?(file) || file == zip_file_path.to_s
|
next if File.directory?(file) || file == zip_file_path.to_s
|
||||||
|
|
||||||
relative_path = file.sub(export_directory.to_s + '/', '')
|
relative_path = file.sub(export_directory.to_s + '/', '')
|
||||||
|
|
||||||
# Add file with specific compression settings
|
# Add file to the zip archive
|
||||||
zipfile.add(relative_path, file) do |entry|
|
zipfile.add(relative_path, file)
|
||||||
# JSON files compress very well, so use maximum compression
|
|
||||||
if file.end_with?('.json')
|
|
||||||
entry.compression_level = 9
|
|
||||||
else
|
|
||||||
# For other files (images, etc.), use balanced compression
|
|
||||||
entry.compression_level = 6
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
ensure
|
||||||
|
# Restore original compression level
|
||||||
|
Zip.default_compression = original_compression if original_compression
|
||||||
end
|
end
|
||||||
|
|
||||||
def cleanup_temporary_files(export_directory)
|
def cleanup_temporary_files(export_directory)
|
||||||
|
|
@ -334,14 +363,17 @@ class Users::ExportData
|
||||||
Rails.logger.info "Cleaning up temporary export directory: #{export_directory}"
|
Rails.logger.info "Cleaning up temporary export directory: #{export_directory}"
|
||||||
FileUtils.rm_rf(export_directory)
|
FileUtils.rm_rf(export_directory)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
ExceptionReporter.call(e)
|
ExceptionReporter.call(e, 'Failed to cleanup temporary files')
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_success_notification
|
def create_success_notification
|
||||||
|
counts = calculate_entity_counts
|
||||||
|
summary = "#{counts[:points]} points, #{counts[:visits]} visits, #{counts[:places]} places, #{counts[:trips]} trips"
|
||||||
|
|
||||||
::Notifications::Create.new(
|
::Notifications::Create.new(
|
||||||
user: user,
|
user: user,
|
||||||
title: 'Export completed',
|
title: 'Export completed',
|
||||||
content: 'Your data export has been processed successfully. You can download it from the exports page.',
|
content: "Your data export has been processed successfully (#{summary}). You can download it from the exports page.",
|
||||||
kind: :info
|
kind: :info
|
||||||
).call
|
).call
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,15 @@ class Users::ExportData::Points
|
||||||
# Single optimized query with all joins to avoid N+1 queries
|
# Single optimized query with all joins to avoid N+1 queries
|
||||||
points_sql = <<-SQL
|
points_sql = <<-SQL
|
||||||
SELECT
|
SELECT
|
||||||
p.battery_status, p.battery, p.timestamp, p.altitude, p.velocity, p.accuracy,
|
p.id, p.battery_status, p.battery, p.timestamp, p.altitude, p.velocity, p.accuracy,
|
||||||
p.ping, p.tracker_id, p.topic, p.trigger, p.bssid, p.ssid, p.connection,
|
p.ping, p.tracker_id, p.topic, p.trigger, p.bssid, p.ssid, p.connection,
|
||||||
p.vertical_accuracy, p.mode, p.inrids, p.in_regions, p.raw_data,
|
p.vertical_accuracy, p.mode, p.inrids, p.in_regions, p.raw_data,
|
||||||
p.city, p.country, p.geodata, p.reverse_geocoded_at, p.course,
|
p.city, p.country, p.geodata, p.reverse_geocoded_at, p.course,
|
||||||
p.course_accuracy, p.external_track_id, p.created_at, p.updated_at,
|
p.course_accuracy, p.external_track_id, p.created_at, p.updated_at,
|
||||||
p.lonlat,
|
p.lonlat, p.longitude, p.latitude,
|
||||||
ST_X(p.lonlat::geometry) as longitude,
|
-- Extract coordinates from lonlat if individual fields are missing
|
||||||
ST_Y(p.lonlat::geometry) as latitude,
|
COALESCE(p.longitude, ST_X(p.lonlat::geometry)) as computed_longitude,
|
||||||
|
COALESCE(p.latitude, ST_Y(p.lonlat::geometry)) as computed_latitude,
|
||||||
-- Import reference
|
-- Import reference
|
||||||
i.name as import_name,
|
i.name as import_name,
|
||||||
i.source as import_source,
|
i.source as import_source,
|
||||||
|
|
@ -42,7 +43,16 @@ class Users::ExportData::Points
|
||||||
Rails.logger.info "Processing #{result.count} points for export..."
|
Rails.logger.info "Processing #{result.count} points for export..."
|
||||||
|
|
||||||
# Process results efficiently
|
# Process results efficiently
|
||||||
result.map do |row|
|
result.filter_map do |row|
|
||||||
|
# Skip points without any coordinate data
|
||||||
|
has_lonlat = row['lonlat'].present?
|
||||||
|
has_coordinates = row['computed_longitude'].present? && row['computed_latitude'].present?
|
||||||
|
|
||||||
|
unless has_lonlat || has_coordinates
|
||||||
|
Rails.logger.debug "Skipping point without coordinates: id=#{row['id'] || 'unknown'}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
point_hash = {
|
point_hash = {
|
||||||
'battery_status' => row['battery_status'],
|
'battery_status' => row['battery_status'],
|
||||||
'battery' => row['battery'],
|
'battery' => row['battery'],
|
||||||
|
|
@ -70,11 +80,12 @@ class Users::ExportData::Points
|
||||||
'course_accuracy' => row['course_accuracy'],
|
'course_accuracy' => row['course_accuracy'],
|
||||||
'external_track_id' => row['external_track_id'],
|
'external_track_id' => row['external_track_id'],
|
||||||
'created_at' => row['created_at'],
|
'created_at' => row['created_at'],
|
||||||
'updated_at' => row['updated_at'],
|
'updated_at' => row['updated_at']
|
||||||
'longitude' => row['longitude'],
|
|
||||||
'latitude' => row['latitude']
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Ensure all coordinate fields are populated
|
||||||
|
populate_coordinate_fields(point_hash, row)
|
||||||
|
|
||||||
# Add relationship references only if they exist
|
# Add relationship references only if they exist
|
||||||
if row['import_name']
|
if row['import_name']
|
||||||
point_hash['import_reference'] = {
|
point_hash['import_reference'] = {
|
||||||
|
|
@ -107,4 +118,22 @@ class Users::ExportData::Points
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :user
|
attr_reader :user
|
||||||
|
|
||||||
|
def populate_coordinate_fields(point_hash, row)
|
||||||
|
longitude = row['computed_longitude']
|
||||||
|
latitude = row['computed_latitude']
|
||||||
|
lonlat = row['lonlat']
|
||||||
|
|
||||||
|
# If lonlat is present, use it and the computed coordinates
|
||||||
|
if lonlat.present?
|
||||||
|
point_hash['lonlat'] = lonlat
|
||||||
|
point_hash['longitude'] = longitude
|
||||||
|
point_hash['latitude'] = latitude
|
||||||
|
elsif longitude.present? && latitude.present?
|
||||||
|
# If lonlat is missing but we have coordinates, reconstruct lonlat
|
||||||
|
point_hash['longitude'] = longitude
|
||||||
|
point_hash['latitude'] = latitude
|
||||||
|
point_hash['lonlat'] = "POINT(#{longitude} #{latitude})"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
202
app/services/users/import_data.rb
Normal file
202
app/services/users/import_data.rb
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'zip'
|
||||||
|
|
||||||
|
# Users::ImportData - Imports complete user data from exported archive
|
||||||
|
#
|
||||||
|
# This service processes a ZIP archive created by Users::ExportData and recreates
|
||||||
|
# the user's data with preserved relationships. The import follows a specific order
|
||||||
|
# to handle foreign key dependencies:
|
||||||
|
#
|
||||||
|
# 1. Settings (applied directly to user)
|
||||||
|
# 2. Areas (standalone user data)
|
||||||
|
# 3. Places (referenced by visits)
|
||||||
|
# 4. Imports (including file attachments)
|
||||||
|
# 5. Exports (including file attachments)
|
||||||
|
# 6. Trips (standalone user data)
|
||||||
|
# 7. Stats (standalone user data)
|
||||||
|
# 8. Notifications (standalone user data)
|
||||||
|
# 9. Visits (references places)
|
||||||
|
# 10. Points (references imports, countries, visits)
|
||||||
|
#
|
||||||
|
# Files are restored to their original locations and properly attached to records.
|
||||||
|
|
||||||
|
class Users::ImportData
|
||||||
|
def initialize(user, archive_path)
|
||||||
|
@user = user
|
||||||
|
@archive_path = archive_path
|
||||||
|
@import_stats = {
|
||||||
|
settings_updated: false,
|
||||||
|
areas_created: 0,
|
||||||
|
places_created: 0,
|
||||||
|
imports_created: 0,
|
||||||
|
exports_created: 0,
|
||||||
|
trips_created: 0,
|
||||||
|
stats_created: 0,
|
||||||
|
notifications_created: 0,
|
||||||
|
visits_created: 0,
|
||||||
|
points_created: 0,
|
||||||
|
files_restored: 0
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def import
|
||||||
|
# Create a temporary directory for extraction
|
||||||
|
@import_directory = Rails.root.join('tmp', "import_#{user.email.gsub(/[^0-9A-Za-z._-]/, '_')}_#{Time.current.to_i}")
|
||||||
|
FileUtils.mkdir_p(@import_directory)
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
extract_archive
|
||||||
|
data = load_json_data
|
||||||
|
|
||||||
|
import_in_correct_order(data)
|
||||||
|
|
||||||
|
create_success_notification
|
||||||
|
|
||||||
|
@import_stats
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
ExceptionReporter.call(e, 'Data import failed')
|
||||||
|
create_failure_notification(e)
|
||||||
|
raise e
|
||||||
|
ensure
|
||||||
|
cleanup_temporary_files(@import_directory) if @import_directory&.exist?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :archive_path, :import_stats
|
||||||
|
|
||||||
|
def extract_archive
|
||||||
|
Rails.logger.info "Extracting archive: #{archive_path}"
|
||||||
|
|
||||||
|
Zip::File.open(archive_path) do |zip_file|
|
||||||
|
zip_file.each do |entry|
|
||||||
|
extraction_path = @import_directory.join(entry.name)
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
FileUtils.mkdir_p(File.dirname(extraction_path))
|
||||||
|
|
||||||
|
# Extract file
|
||||||
|
entry.extract(extraction_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_json_data
|
||||||
|
json_path = @import_directory.join('data.json')
|
||||||
|
|
||||||
|
unless File.exist?(json_path)
|
||||||
|
raise StandardError, "Data file not found in archive: data.json"
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.parse(File.read(json_path))
|
||||||
|
rescue JSON::ParserError => e
|
||||||
|
raise StandardError, "Invalid JSON format in data file: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_in_correct_order(data)
|
||||||
|
Rails.logger.info "Starting data import for user: #{user.email}"
|
||||||
|
|
||||||
|
# Log expected counts if available
|
||||||
|
if data['counts']
|
||||||
|
Rails.logger.info "Expected entity counts from export: #{data['counts']}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Import in dependency order
|
||||||
|
import_settings(data['settings']) if data['settings']
|
||||||
|
import_areas(data['areas']) if data['areas']
|
||||||
|
import_places(data['places']) if data['places']
|
||||||
|
import_imports(data['imports']) if data['imports']
|
||||||
|
import_exports(data['exports']) if data['exports']
|
||||||
|
import_trips(data['trips']) if data['trips']
|
||||||
|
import_stats(data['stats']) if data['stats']
|
||||||
|
import_notifications(data['notifications']) if data['notifications']
|
||||||
|
import_visits(data['visits']) if data['visits']
|
||||||
|
import_points(data['points']) if data['points']
|
||||||
|
|
||||||
|
Rails.logger.info "Data import completed. Stats: #{@import_stats}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_settings(settings_data)
|
||||||
|
Users::ImportData::Settings.new(user, settings_data).call
|
||||||
|
@import_stats[:settings_updated] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_areas(areas_data)
|
||||||
|
areas_created = Users::ImportData::Areas.new(user, areas_data).call
|
||||||
|
@import_stats[:areas_created] = areas_created
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_places(places_data)
|
||||||
|
places_created = Users::ImportData::Places.new(user, places_data).call
|
||||||
|
@import_stats[:places_created] = places_created
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_imports(imports_data)
|
||||||
|
imports_created, files_restored = Users::ImportData::Imports.new(user, imports_data, @import_directory.join('files')).call
|
||||||
|
@import_stats[:imports_created] = imports_created
|
||||||
|
@import_stats[:files_restored] += files_restored
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_exports(exports_data)
|
||||||
|
exports_created, files_restored = Users::ImportData::Exports.new(user, exports_data, @import_directory.join('files')).call
|
||||||
|
@import_stats[:exports_created] = exports_created
|
||||||
|
@import_stats[:files_restored] += files_restored
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_trips(trips_data)
|
||||||
|
trips_created = Users::ImportData::Trips.new(user, trips_data).call
|
||||||
|
@import_stats[:trips_created] = trips_created
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_stats(stats_data)
|
||||||
|
stats_created = Users::ImportData::Stats.new(user, stats_data).call
|
||||||
|
@import_stats[:stats_created] = stats_created
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_notifications(notifications_data)
|
||||||
|
notifications_created = Users::ImportData::Notifications.new(user, notifications_data).call
|
||||||
|
@import_stats[:notifications_created] = notifications_created
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_visits(visits_data)
|
||||||
|
visits_created = Users::ImportData::Visits.new(user, visits_data).call
|
||||||
|
@import_stats[:visits_created] = visits_created
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_points(points_data)
|
||||||
|
points_created = Users::ImportData::Points.new(user, points_data).call
|
||||||
|
@import_stats[:points_created] = points_created
|
||||||
|
end
|
||||||
|
|
||||||
|
def cleanup_temporary_files(import_directory)
|
||||||
|
return unless File.directory?(import_directory)
|
||||||
|
|
||||||
|
Rails.logger.info "Cleaning up temporary import directory: #{import_directory}"
|
||||||
|
FileUtils.rm_rf(import_directory)
|
||||||
|
rescue StandardError => e
|
||||||
|
ExceptionReporter.call(e, 'Failed to cleanup temporary files')
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_success_notification
|
||||||
|
summary = "#{@import_stats[:points_created]} points, #{@import_stats[:visits_created]} visits, " \
|
||||||
|
"#{@import_stats[:places_created]} places, #{@import_stats[:trips_created]} trips"
|
||||||
|
|
||||||
|
::Notifications::Create.new(
|
||||||
|
user: user,
|
||||||
|
title: 'Data import completed',
|
||||||
|
content: "Your data has been imported successfully (#{summary}).",
|
||||||
|
kind: :info
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_failure_notification(error)
|
||||||
|
::Notifications::Create.new(
|
||||||
|
user: user,
|
||||||
|
title: 'Data import failed',
|
||||||
|
content: "Your data import failed with error: #{error.message}. Please check the archive format and try again.",
|
||||||
|
kind: :error
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
end
|
||||||
53
app/services/users/import_data/areas.rb
Normal file
53
app/services/users/import_data/areas.rb
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ImportData::Areas
|
||||||
|
def initialize(user, areas_data)
|
||||||
|
@user = user
|
||||||
|
@areas_data = areas_data
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return 0 unless areas_data.is_a?(Array)
|
||||||
|
|
||||||
|
Rails.logger.info "Importing #{areas_data.size} areas for user: #{user.email}"
|
||||||
|
|
||||||
|
areas_created = 0
|
||||||
|
|
||||||
|
areas_data.each do |area_data|
|
||||||
|
next unless area_data.is_a?(Hash)
|
||||||
|
|
||||||
|
# Skip if area already exists (match by name and coordinates)
|
||||||
|
existing_area = user.areas.find_by(
|
||||||
|
name: area_data['name'],
|
||||||
|
latitude: area_data['latitude'],
|
||||||
|
longitude: area_data['longitude']
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_area
|
||||||
|
Rails.logger.debug "Area already exists: #{area_data['name']}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new area
|
||||||
|
area_attributes = area_data.merge(user: user)
|
||||||
|
# Ensure radius is present (required by model validation)
|
||||||
|
area_attributes['radius'] ||= 100 # Default radius if not provided
|
||||||
|
|
||||||
|
area = user.areas.create!(area_attributes)
|
||||||
|
areas_created += 1
|
||||||
|
|
||||||
|
Rails.logger.debug "Created area: #{area.name}"
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
ExceptionReporter.call(e, "Failed to create area")
|
||||||
|
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Areas import completed. Created: #{areas_created}"
|
||||||
|
areas_created
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :areas_data
|
||||||
|
end
|
||||||
92
app/services/users/import_data/exports.rb
Normal file
92
app/services/users/import_data/exports.rb
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ImportData::Exports
|
||||||
|
def initialize(user, exports_data, files_directory)
|
||||||
|
@user = user
|
||||||
|
@exports_data = exports_data
|
||||||
|
@files_directory = files_directory
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return [0, 0] unless exports_data.is_a?(Array)
|
||||||
|
|
||||||
|
Rails.logger.info "Importing #{exports_data.size} exports for user: #{user.email}"
|
||||||
|
|
||||||
|
exports_created = 0
|
||||||
|
files_restored = 0
|
||||||
|
|
||||||
|
exports_data.each do |export_data|
|
||||||
|
next unless export_data.is_a?(Hash)
|
||||||
|
|
||||||
|
# Check if export already exists (match by name and created_at)
|
||||||
|
existing_export = user.exports.find_by(
|
||||||
|
name: export_data['name'],
|
||||||
|
created_at: export_data['created_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_export
|
||||||
|
Rails.logger.debug "Export already exists: #{export_data['name']}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new export
|
||||||
|
export_record = create_export_record(export_data)
|
||||||
|
exports_created += 1
|
||||||
|
|
||||||
|
# Restore file if present
|
||||||
|
if export_data['file_name'] && restore_export_file(export_record, export_data)
|
||||||
|
files_restored += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.debug "Created export: #{export_record.name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Exports import completed. Created: #{exports_created}, Files: #{files_restored}"
|
||||||
|
[exports_created, files_restored]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :exports_data, :files_directory
|
||||||
|
|
||||||
|
def create_export_record(export_data)
|
||||||
|
export_attributes = prepare_export_attributes(export_data)
|
||||||
|
user.exports.create!(export_attributes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def prepare_export_attributes(export_data)
|
||||||
|
export_data.except(
|
||||||
|
'file_name',
|
||||||
|
'original_filename',
|
||||||
|
'file_size',
|
||||||
|
'content_type',
|
||||||
|
'file_error'
|
||||||
|
).merge(user: user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def restore_export_file(export_record, export_data)
|
||||||
|
file_path = files_directory.join(export_data['file_name'])
|
||||||
|
|
||||||
|
unless File.exist?(file_path)
|
||||||
|
Rails.logger.warn "Export file not found: #{export_data['file_name']}"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Attach the file to the export record
|
||||||
|
export_record.file.attach(
|
||||||
|
io: File.open(file_path),
|
||||||
|
filename: export_data['original_filename'] || export_data['file_name'],
|
||||||
|
content_type: export_data['content_type'] || 'application/octet-stream'
|
||||||
|
)
|
||||||
|
|
||||||
|
Rails.logger.debug "Restored file for export: #{export_record.name}"
|
||||||
|
|
||||||
|
true
|
||||||
|
rescue StandardError => e
|
||||||
|
ExceptionReporter.call(e, "Export file restoration failed")
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
102
app/services/users/import_data/imports.rb
Normal file
102
app/services/users/import_data/imports.rb
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ImportData::Imports
|
||||||
|
def initialize(user, imports_data, files_directory)
|
||||||
|
@user = user
|
||||||
|
@imports_data = imports_data
|
||||||
|
@files_directory = files_directory
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return [0, 0] unless imports_data.is_a?(Array)
|
||||||
|
|
||||||
|
Rails.logger.info "Importing #{imports_data.size} imports for user: #{user.email}"
|
||||||
|
|
||||||
|
imports_created = 0
|
||||||
|
files_restored = 0
|
||||||
|
|
||||||
|
imports_data.each do |import_data|
|
||||||
|
next unless import_data.is_a?(Hash)
|
||||||
|
|
||||||
|
# Check if import already exists (match by name, source, and created_at)
|
||||||
|
existing_import = user.imports.find_by(
|
||||||
|
name: import_data['name'],
|
||||||
|
source: import_data['source'],
|
||||||
|
created_at: import_data['created_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_import
|
||||||
|
Rails.logger.debug "Import already exists: #{import_data['name']}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new import
|
||||||
|
import_record = create_import_record(import_data)
|
||||||
|
next unless import_record # Skip if creation failed
|
||||||
|
|
||||||
|
imports_created += 1
|
||||||
|
|
||||||
|
# Restore file if present
|
||||||
|
if import_data['file_name'] && restore_import_file(import_record, import_data)
|
||||||
|
files_restored += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Imports import completed. Created: #{imports_created}, Files restored: #{files_restored}"
|
||||||
|
[imports_created, files_restored]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :imports_data, :files_directory
|
||||||
|
|
||||||
|
def create_import_record(import_data)
|
||||||
|
import_attributes = prepare_import_attributes(import_data)
|
||||||
|
|
||||||
|
begin
|
||||||
|
import_record = user.imports.create!(import_attributes)
|
||||||
|
Rails.logger.debug "Created import: #{import_record.name}"
|
||||||
|
import_record
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
Rails.logger.error "Failed to create import: #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def prepare_import_attributes(import_data)
|
||||||
|
import_data.except(
|
||||||
|
'file_name',
|
||||||
|
'original_filename',
|
||||||
|
'file_size',
|
||||||
|
'content_type',
|
||||||
|
'file_error',
|
||||||
|
'updated_at'
|
||||||
|
).merge(user: user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def restore_import_file(import_record, import_data)
|
||||||
|
file_path = files_directory.join(import_data['file_name'])
|
||||||
|
|
||||||
|
unless File.exist?(file_path)
|
||||||
|
Rails.logger.warn "Import file not found: #{import_data['file_name']}"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Attach the file to the import record
|
||||||
|
import_record.file.attach(
|
||||||
|
io: File.open(file_path),
|
||||||
|
filename: import_data['original_filename'] || import_data['file_name'],
|
||||||
|
content_type: import_data['content_type'] || 'application/octet-stream'
|
||||||
|
)
|
||||||
|
|
||||||
|
Rails.logger.debug "Restored file for import: #{import_record.name}"
|
||||||
|
|
||||||
|
true
|
||||||
|
rescue StandardError => e
|
||||||
|
ExceptionReporter.call(e, "Import file restoration failed")
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
49
app/services/users/import_data/notifications.rb
Normal file
49
app/services/users/import_data/notifications.rb
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ImportData::Notifications
|
||||||
|
def initialize(user, notifications_data)
|
||||||
|
@user = user
|
||||||
|
@notifications_data = notifications_data
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return 0 unless notifications_data.is_a?(Array)
|
||||||
|
|
||||||
|
Rails.logger.info "Importing #{notifications_data.size} notifications for user: #{user.email}"
|
||||||
|
|
||||||
|
notifications_created = 0
|
||||||
|
|
||||||
|
notifications_data.each do |notification_data|
|
||||||
|
next unless notification_data.is_a?(Hash)
|
||||||
|
|
||||||
|
# Check if notification already exists (match by title, content, and created_at)
|
||||||
|
existing_notification = user.notifications.find_by(
|
||||||
|
title: notification_data['title'],
|
||||||
|
content: notification_data['content'],
|
||||||
|
created_at: notification_data['created_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_notification
|
||||||
|
Rails.logger.debug "Notification already exists: #{notification_data['title']}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new notification
|
||||||
|
notification_attributes = notification_data.except('created_at', 'updated_at')
|
||||||
|
notification = user.notifications.create!(notification_attributes)
|
||||||
|
notifications_created += 1
|
||||||
|
|
||||||
|
Rails.logger.debug "Created notification: #{notification.title}"
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
Rails.logger.error "Failed to create notification: #{e.message}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Notifications import completed. Created: #{notifications_created}"
|
||||||
|
notifications_created
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :notifications_data
|
||||||
|
end
|
||||||
76
app/services/users/import_data/places.rb
Normal file
76
app/services/users/import_data/places.rb
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ImportData::Places
|
||||||
|
def initialize(user, places_data)
|
||||||
|
@user = user
|
||||||
|
@places_data = places_data
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return 0 unless places_data.is_a?(Array)
|
||||||
|
|
||||||
|
Rails.logger.info "Importing #{places_data.size} places for user: #{user.email}"
|
||||||
|
|
||||||
|
places_created = 0
|
||||||
|
|
||||||
|
places_data.each do |place_data|
|
||||||
|
next unless place_data.is_a?(Hash)
|
||||||
|
|
||||||
|
# Find or create place by name and coordinates
|
||||||
|
place = find_or_create_place(place_data)
|
||||||
|
places_created += 1 if place&.respond_to?(:previously_new_record?) && place.previously_new_record?
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Places import completed. Created: #{places_created}"
|
||||||
|
places_created
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :places_data
|
||||||
|
|
||||||
|
def find_or_create_place(place_data)
|
||||||
|
name = place_data['name']
|
||||||
|
latitude = place_data['latitude']&.to_f
|
||||||
|
longitude = place_data['longitude']&.to_f
|
||||||
|
|
||||||
|
# Skip if essential data is missing
|
||||||
|
unless name.present? && latitude.present? && longitude.present?
|
||||||
|
Rails.logger.debug "Skipping place with missing required data: #{place_data.inspect}"
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Try to find existing place by name first, then by coordinates
|
||||||
|
existing_place = Place.find_by(name: name)
|
||||||
|
|
||||||
|
# If no place with same name, check by coordinates
|
||||||
|
unless existing_place
|
||||||
|
existing_place = Place.where(latitude: latitude, longitude: longitude).first
|
||||||
|
end
|
||||||
|
|
||||||
|
if existing_place
|
||||||
|
Rails.logger.debug "Place already exists: #{name}"
|
||||||
|
existing_place.define_singleton_method(:previously_new_record?) { false }
|
||||||
|
return existing_place
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new place with lonlat point
|
||||||
|
place_attributes = place_data.except('created_at', 'updated_at', 'latitude', 'longitude')
|
||||||
|
place_attributes['lonlat'] = "POINT(#{longitude} #{latitude})"
|
||||||
|
place_attributes['latitude'] = latitude
|
||||||
|
place_attributes['longitude'] = longitude
|
||||||
|
# Remove any user reference since Place doesn't belong to user directly
|
||||||
|
place_attributes.delete('user')
|
||||||
|
|
||||||
|
begin
|
||||||
|
place = Place.create!(place_attributes)
|
||||||
|
place.define_singleton_method(:previously_new_record?) { true }
|
||||||
|
Rails.logger.debug "Created place: #{place.name}"
|
||||||
|
|
||||||
|
place
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
Rails.logger.error "Failed to create place: #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
191
app/services/users/import_data/points.rb
Normal file
191
app/services/users/import_data/points.rb
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ImportData::Points
|
||||||
|
def initialize(user, points_data)
|
||||||
|
@user = user
|
||||||
|
@points_data = points_data
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return 0 unless points_data.is_a?(Array)
|
||||||
|
|
||||||
|
Rails.logger.info "Importing #{points_data.size} points for user: #{user.email}"
|
||||||
|
|
||||||
|
points_created = 0
|
||||||
|
skipped_invalid = 0
|
||||||
|
|
||||||
|
points_data.each do |point_data|
|
||||||
|
next unless point_data.is_a?(Hash)
|
||||||
|
|
||||||
|
# Skip points with invalid or missing required data
|
||||||
|
unless valid_point_data?(point_data)
|
||||||
|
skipped_invalid += 1
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if point already exists (match by coordinates, timestamp, and user)
|
||||||
|
if point_exists?(point_data)
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new point
|
||||||
|
point_record = create_point_record(point_data)
|
||||||
|
points_created += 1 if point_record
|
||||||
|
|
||||||
|
if points_created % 1000 == 0
|
||||||
|
Rails.logger.debug "Imported #{points_created} points..."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if skipped_invalid > 0
|
||||||
|
Rails.logger.warn "Skipped #{skipped_invalid} points with invalid or missing required data"
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Points import completed. Created: #{points_created}"
|
||||||
|
points_created
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :points_data
|
||||||
|
|
||||||
|
def point_exists?(point_data)
|
||||||
|
return false unless point_data['lonlat'].present? && point_data['timestamp'].present?
|
||||||
|
|
||||||
|
Point.exists?(
|
||||||
|
lonlat: point_data['lonlat'],
|
||||||
|
timestamp: point_data['timestamp'],
|
||||||
|
user_id: user.id
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.debug "Error checking if point exists: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_point_record(point_data)
|
||||||
|
point_attributes = prepare_point_attributes(point_data)
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Create point and skip the automatic country assignment callback since we're handling it manually
|
||||||
|
point = Point.create!(point_attributes)
|
||||||
|
|
||||||
|
# If we have a country assigned via country_info, update the point to set it
|
||||||
|
if point_attributes[:country].present?
|
||||||
|
point.update_column(:country_id, point_attributes[:country].id)
|
||||||
|
point.reload
|
||||||
|
end
|
||||||
|
|
||||||
|
point
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
Rails.logger.error "Failed to create point: #{e.message}"
|
||||||
|
Rails.logger.error "Point data: #{point_data.inspect}"
|
||||||
|
Rails.logger.error "Prepared attributes: #{point_attributes.inspect}"
|
||||||
|
nil
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Unexpected error creating point: #{e.message}"
|
||||||
|
Rails.logger.error "Point data: #{point_data.inspect}"
|
||||||
|
Rails.logger.error "Prepared attributes: #{point_attributes.inspect}"
|
||||||
|
Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def prepare_point_attributes(point_data)
|
||||||
|
# Start with base attributes, excluding fields that need special handling
|
||||||
|
attributes = point_data.except(
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'import_reference',
|
||||||
|
'country_info',
|
||||||
|
'visit_reference',
|
||||||
|
'country' # Exclude the string country field - handled via country_info relationship
|
||||||
|
).merge(user: user)
|
||||||
|
|
||||||
|
# Handle lonlat reconstruction if missing (for backward compatibility)
|
||||||
|
ensure_lonlat_field(attributes, point_data)
|
||||||
|
|
||||||
|
# Find and assign related records
|
||||||
|
assign_import_reference(attributes, point_data['import_reference'])
|
||||||
|
assign_country_reference(attributes, point_data['country_info'])
|
||||||
|
assign_visit_reference(attributes, point_data['visit_reference'])
|
||||||
|
|
||||||
|
attributes
|
||||||
|
end
|
||||||
|
|
||||||
|
def assign_import_reference(attributes, import_reference)
|
||||||
|
return unless import_reference.is_a?(Hash)
|
||||||
|
|
||||||
|
import = user.imports.find_by(
|
||||||
|
name: import_reference['name'],
|
||||||
|
source: import_reference['source'],
|
||||||
|
created_at: import_reference['created_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
attributes[:import] = import if import
|
||||||
|
end
|
||||||
|
|
||||||
|
def assign_country_reference(attributes, country_info)
|
||||||
|
return unless country_info.is_a?(Hash)
|
||||||
|
|
||||||
|
# Try to find country by all attributes first
|
||||||
|
country = Country.find_by(
|
||||||
|
name: country_info['name'],
|
||||||
|
iso_a2: country_info['iso_a2'],
|
||||||
|
iso_a3: country_info['iso_a3']
|
||||||
|
)
|
||||||
|
|
||||||
|
# If not found by all attributes, try to find by name only
|
||||||
|
if country.nil? && country_info['name'].present?
|
||||||
|
country = Country.find_by(name: country_info['name'])
|
||||||
|
end
|
||||||
|
|
||||||
|
# If still not found, create a new country record with minimal data
|
||||||
|
if country.nil? && country_info['name'].present?
|
||||||
|
country = Country.find_or_create_by(name: country_info['name']) do |new_country|
|
||||||
|
new_country.iso_a2 = country_info['iso_a2'] || country_info['name'][0..1].upcase
|
||||||
|
new_country.iso_a3 = country_info['iso_a3'] || country_info['name'][0..2].upcase
|
||||||
|
new_country.geom = "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)))" # Default geometry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes[:country] = country if country
|
||||||
|
end
|
||||||
|
|
||||||
|
def assign_visit_reference(attributes, visit_reference)
|
||||||
|
return unless visit_reference.is_a?(Hash)
|
||||||
|
|
||||||
|
visit = user.visits.find_by(
|
||||||
|
name: visit_reference['name'],
|
||||||
|
started_at: visit_reference['started_at'],
|
||||||
|
ended_at: visit_reference['ended_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
attributes[:visit] = visit if visit
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_point_data?(point_data)
|
||||||
|
# Check for required fields
|
||||||
|
return false unless point_data.is_a?(Hash)
|
||||||
|
return false unless point_data['timestamp'].present?
|
||||||
|
|
||||||
|
# Check if we have either lonlat or longitude/latitude
|
||||||
|
has_lonlat = point_data['lonlat'].present? && point_data['lonlat'].is_a?(String) && point_data['lonlat'].start_with?('POINT(')
|
||||||
|
has_coordinates = point_data['longitude'].present? && point_data['latitude'].present?
|
||||||
|
|
||||||
|
return false unless has_lonlat || has_coordinates
|
||||||
|
|
||||||
|
true
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.debug "Point validation failed: #{e.message} for data: #{point_data.inspect}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_lonlat_field(attributes, point_data)
|
||||||
|
# If lonlat is missing but we have longitude/latitude, reconstruct it
|
||||||
|
if attributes['lonlat'].blank? && point_data['longitude'].present? && point_data['latitude'].present?
|
||||||
|
longitude = point_data['longitude'].to_f
|
||||||
|
latitude = point_data['latitude'].to_f
|
||||||
|
attributes['lonlat'] = "POINT(#{longitude} #{latitude})"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
27
app/services/users/import_data/settings.rb
Normal file
27
app/services/users/import_data/settings.rb
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ImportData::Settings
|
||||||
|
def initialize(user, settings_data)
|
||||||
|
@user = user
|
||||||
|
@settings_data = settings_data
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return false unless settings_data.is_a?(Hash)
|
||||||
|
|
||||||
|
Rails.logger.info "Importing settings for user: #{user.email}"
|
||||||
|
|
||||||
|
# Merge imported settings with existing settings
|
||||||
|
current_settings = user.settings || {}
|
||||||
|
updated_settings = current_settings.merge(settings_data)
|
||||||
|
|
||||||
|
user.update!(settings: updated_settings)
|
||||||
|
|
||||||
|
Rails.logger.info "Settings import completed"
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :settings_data
|
||||||
|
end
|
||||||
48
app/services/users/import_data/stats.rb
Normal file
48
app/services/users/import_data/stats.rb
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ImportData::Stats
|
||||||
|
def initialize(user, stats_data)
|
||||||
|
@user = user
|
||||||
|
@stats_data = stats_data
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return 0 unless stats_data.is_a?(Array)
|
||||||
|
|
||||||
|
Rails.logger.info "Importing #{stats_data.size} stats for user: #{user.email}"
|
||||||
|
|
||||||
|
stats_created = 0
|
||||||
|
|
||||||
|
stats_data.each do |stat_data|
|
||||||
|
next unless stat_data.is_a?(Hash)
|
||||||
|
|
||||||
|
# Check if stat already exists (match by year and month)
|
||||||
|
existing_stat = user.stats.find_by(
|
||||||
|
year: stat_data['year'],
|
||||||
|
month: stat_data['month']
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_stat
|
||||||
|
Rails.logger.debug "Stat already exists: #{stat_data['year']}-#{stat_data['month']}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new stat
|
||||||
|
stat_attributes = stat_data.except('created_at', 'updated_at')
|
||||||
|
stat = user.stats.create!(stat_attributes)
|
||||||
|
stats_created += 1
|
||||||
|
|
||||||
|
Rails.logger.debug "Created stat: #{stat.year}-#{stat.month}"
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
Rails.logger.error "Failed to create stat: #{e.message}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Stats import completed. Created: #{stats_created}"
|
||||||
|
stats_created
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :stats_data
|
||||||
|
end
|
||||||
49
app/services/users/import_data/trips.rb
Normal file
49
app/services/users/import_data/trips.rb
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ImportData::Trips
|
||||||
|
def initialize(user, trips_data)
|
||||||
|
@user = user
|
||||||
|
@trips_data = trips_data
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return 0 unless trips_data.is_a?(Array)
|
||||||
|
|
||||||
|
Rails.logger.info "Importing #{trips_data.size} trips for user: #{user.email}"
|
||||||
|
|
||||||
|
trips_created = 0
|
||||||
|
|
||||||
|
trips_data.each do |trip_data|
|
||||||
|
next unless trip_data.is_a?(Hash)
|
||||||
|
|
||||||
|
# Check if trip already exists (match by name and timestamps)
|
||||||
|
existing_trip = user.trips.find_by(
|
||||||
|
name: trip_data['name'],
|
||||||
|
started_at: trip_data['started_at'],
|
||||||
|
ended_at: trip_data['ended_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_trip
|
||||||
|
Rails.logger.debug "Trip already exists: #{trip_data['name']}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new trip
|
||||||
|
trip_attributes = trip_data.except('created_at', 'updated_at')
|
||||||
|
trip = user.trips.create!(trip_attributes)
|
||||||
|
trips_created += 1
|
||||||
|
|
||||||
|
Rails.logger.debug "Created trip: #{trip.name}"
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
Rails.logger.error "Failed to create trip: #{e.message}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Trips import completed. Created: #{trips_created}"
|
||||||
|
trips_created
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :trips_data
|
||||||
|
end
|
||||||
90
app/services/users/import_data/visits.rb
Normal file
90
app/services/users/import_data/visits.rb
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ImportData::Visits
|
||||||
|
def initialize(user, visits_data)
|
||||||
|
@user = user
|
||||||
|
@visits_data = visits_data
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return 0 unless visits_data.is_a?(Array)
|
||||||
|
|
||||||
|
Rails.logger.info "Importing #{visits_data.size} visits for user: #{user.email}"
|
||||||
|
|
||||||
|
visits_created = 0
|
||||||
|
|
||||||
|
visits_data.each do |visit_data|
|
||||||
|
next unless visit_data.is_a?(Hash)
|
||||||
|
|
||||||
|
# Check if visit already exists (match by name, timestamps, and place reference)
|
||||||
|
existing_visit = find_existing_visit(visit_data)
|
||||||
|
|
||||||
|
if existing_visit
|
||||||
|
Rails.logger.debug "Visit already exists: #{visit_data['name']}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new visit
|
||||||
|
begin
|
||||||
|
visit_record = create_visit_record(visit_data)
|
||||||
|
visits_created += 1
|
||||||
|
Rails.logger.debug "Created visit: #{visit_record.name}"
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
Rails.logger.error "Failed to create visit: #{e.message}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Visits import completed. Created: #{visits_created}"
|
||||||
|
visits_created
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :visits_data
|
||||||
|
|
||||||
|
def find_existing_visit(visit_data)
|
||||||
|
user.visits.find_by(
|
||||||
|
name: visit_data['name'],
|
||||||
|
started_at: visit_data['started_at'],
|
||||||
|
ended_at: visit_data['ended_at']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_visit_record(visit_data)
|
||||||
|
visit_attributes = prepare_visit_attributes(visit_data)
|
||||||
|
user.visits.create!(visit_attributes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def prepare_visit_attributes(visit_data)
|
||||||
|
attributes = visit_data.except('place_reference')
|
||||||
|
|
||||||
|
# Find and assign place if referenced
|
||||||
|
if visit_data['place_reference']
|
||||||
|
place = find_referenced_place(visit_data['place_reference'])
|
||||||
|
attributes[:place] = place if place
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_referenced_place(place_reference)
|
||||||
|
return nil unless place_reference.is_a?(Hash)
|
||||||
|
|
||||||
|
name = place_reference['name']
|
||||||
|
latitude = place_reference['latitude'].to_f
|
||||||
|
longitude = place_reference['longitude'].to_f
|
||||||
|
|
||||||
|
# Find place by name and coordinates (global search since places are not user-specific)
|
||||||
|
place = Place.find_by(name: name) ||
|
||||||
|
Place.where("latitude = ? AND longitude = ?", latitude, longitude).first
|
||||||
|
|
||||||
|
if place
|
||||||
|
Rails.logger.debug "Found referenced place: #{name}"
|
||||||
|
else
|
||||||
|
Rails.logger.warn "Referenced place not found: #{name} (#{latitude}, #{longitude})"
|
||||||
|
end
|
||||||
|
|
||||||
|
place
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -64,8 +64,33 @@
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<p class='mt-3 flex flex-col gap-2'>
|
<p class='mt-3 flex flex-col gap-2'>
|
||||||
<%= link_to "Export my data", export_settings_users_path, class: 'btn btn-primary' %>
|
<%= link_to "Export my data", export_settings_users_path, class: 'btn btn-primary' %>
|
||||||
<%= link_to "Import my data", import_settings_users_path, class: 'btn btn-primary' %>
|
<button class='btn btn-primary' onclick="import_modal.showModal()">Import my data</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Import Data Modal -->
|
||||||
|
<dialog id="import_modal" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4">Import your data</h3>
|
||||||
|
<p class="mb-4 text-sm text-gray-600">Upload a ZIP file containing your exported Dawarich data to restore your points, trips, and settings.</p>
|
||||||
|
|
||||||
|
<%= form_with url: import_settings_users_path, method: :post, multipart: true, class: 'space-y-4', data: { turbo: false } do |f| %>
|
||||||
|
<div class="form-control">
|
||||||
|
<%= f.label :archive, class: 'label' do %>
|
||||||
|
<span class="label-text">Select ZIP archive</span>
|
||||||
|
<% end %>
|
||||||
|
<%= f.file_field :archive, accept: '.zip', required: true, class: 'file-input file-input-bordered w-full' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<%= f.submit "Import Data", class: 'btn btn-primary', data: { disable_with: 'Importing...' } %>
|
||||||
|
<button type="button" class="btn" onclick="import_modal.close()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
<% if DawarichSettings.store_geodata? %>
|
<% if DawarichSettings.store_geodata? %>
|
||||||
<th>Reverse geocoded points</th>
|
<th>Reverse geocoded points</th>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<th>Status</th>
|
||||||
<th>Created at</th>
|
<th>Created at</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,8 @@ Rails.application.configure do
|
||||||
|
|
||||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
||||||
|
|
||||||
config.action_mailer.default_url_options = { host: hosts.first, port: 3000 }
|
config.action_mailer.default_url_options = { host: ENV['SMTP_DOMAIN'] || hosts.first }
|
||||||
|
|
||||||
config.hosts.concat(hosts) if hosts.present?
|
config.hosts.concat(hosts) if hosts.present?
|
||||||
|
|
||||||
config.force_ssl = ENV.fetch('APPLICATION_PROTOCOL', 'http').downcase == 'https'
|
config.force_ssl = ENV.fetch('APPLICATION_PROTOCOL', 'http').downcase == 'https'
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ Rails.application.configure do
|
||||||
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
||||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
||||||
|
|
||||||
config.action_mailer.default_url_options = { host: hosts.first, port: 3000 }
|
config.action_mailer.default_url_options = { host: ENV['SMTP_DOMAIN'] }
|
||||||
config.hosts.concat(hosts) if hosts.present?
|
config.hosts.concat(hosts) if hosts.present?
|
||||||
|
|
||||||
config.action_mailer.delivery_method = :smtp
|
config.action_mailer.delivery_method = :smtp
|
||||||
|
|
|
||||||
10
db/migrate/20250627184017_add_status_to_imports.rb
Normal file
10
db/migrate/20250627184017_add_status_to_imports.rb
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddStatusToImports < ActiveRecord::Migration[8.0]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def change
|
||||||
|
add_column :imports, :status, :integer, default: 0, null: false
|
||||||
|
add_index :imports, :status, algorithm: :concurrently
|
||||||
|
end
|
||||||
|
end
|
||||||
17
db/schema.rb
generated
17
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_06_25_185030) do
|
ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
enable_extension "pg_catalog.plpgsql"
|
||||||
enable_extension "postgis"
|
enable_extension "postgis"
|
||||||
|
|
@ -107,7 +107,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_25_185030) do
|
||||||
t.integer "processed", default: 0
|
t.integer "processed", default: 0
|
||||||
t.jsonb "raw_data"
|
t.jsonb "raw_data"
|
||||||
t.integer "points_count", default: 0
|
t.integer "points_count", default: 0
|
||||||
|
t.integer "status", default: 0, null: false
|
||||||
t.index ["source"], name: "index_imports_on_source"
|
t.index ["source"], name: "index_imports_on_source"
|
||||||
|
t.index ["status"], name: "index_imports_on_status"
|
||||||
t.index ["user_id"], name: "index_imports_on_user_id"
|
t.index ["user_id"], name: "index_imports_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -230,6 +232,18 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_25_185030) do
|
||||||
t.index ["user_id"], name: "index_trips_on_user_id"
|
t.index ["user_id"], name: "index_trips_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "user_data_imports", force: :cascade do |t|
|
||||||
|
t.bigint "user_id", null: false
|
||||||
|
t.string "status", default: "pending", null: false
|
||||||
|
t.string "archive_file_name"
|
||||||
|
t.text "error_message"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["status"], name: "index_user_data_imports_on_status"
|
||||||
|
t.index ["user_id", "created_at"], name: "index_user_data_imports_on_user_id_and_created_at"
|
||||||
|
t.index ["user_id"], name: "index_user_data_imports_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "users", force: :cascade do |t|
|
create_table "users", force: :cascade do |t|
|
||||||
t.string "email", default: "", null: false
|
t.string "email", default: "", null: false
|
||||||
t.string "encrypted_password", default: "", null: false
|
t.string "encrypted_password", default: "", null: false
|
||||||
|
|
@ -282,6 +296,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_25_185030) do
|
||||||
add_foreign_key "points", "visits"
|
add_foreign_key "points", "visits"
|
||||||
add_foreign_key "stats", "users"
|
add_foreign_key "stats", "users"
|
||||||
add_foreign_key "trips", "users"
|
add_foreign_key "trips", "users"
|
||||||
|
add_foreign_key "user_data_imports", "users"
|
||||||
add_foreign_key "visits", "areas"
|
add_foreign_key "visits", "areas"
|
||||||
add_foreign_key "visits", "places"
|
add_foreign_key "visits", "places"
|
||||||
add_foreign_key "visits", "users"
|
add_foreign_key "visits", "users"
|
||||||
|
|
|
||||||
183
spec/jobs/users/import_data_job_spec.rb
Normal file
183
spec/jobs/users/import_data_job_spec.rb
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::ImportDataJob, type: :job do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:import) { create(:import, user: user, source: :user_data_archive, name: 'test_export.zip') }
|
||||||
|
let(:archive_path) { Rails.root.join('tmp', 'test_export.zip') }
|
||||||
|
let(:job) { described_class.new }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Create a mock ZIP file
|
||||||
|
FileUtils.touch(archive_path)
|
||||||
|
|
||||||
|
# Mock the import file attachment
|
||||||
|
allow(import).to receive(:file).and_return(
|
||||||
|
double('ActiveStorage::Attached::One',
|
||||||
|
download: proc { |&block|
|
||||||
|
File.read(archive_path).each_char { |c| block.call(c) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
FileUtils.rm_f(archive_path) if File.exist?(archive_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
context 'when import is successful' do
|
||||||
|
before do
|
||||||
|
# Mock the import service
|
||||||
|
import_service = instance_double(Users::ImportData)
|
||||||
|
allow(Users::ImportData).to receive(:new).and_return(import_service)
|
||||||
|
allow(import_service).to receive(:import).and_return({
|
||||||
|
settings_updated: true,
|
||||||
|
areas_created: 2,
|
||||||
|
places_created: 3,
|
||||||
|
imports_created: 1,
|
||||||
|
exports_created: 1,
|
||||||
|
trips_created: 2,
|
||||||
|
stats_created: 1,
|
||||||
|
notifications_created: 2,
|
||||||
|
visits_created: 4,
|
||||||
|
points_created: 1000,
|
||||||
|
files_restored: 7
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mock file operations
|
||||||
|
allow(File).to receive(:exist?).and_return(true)
|
||||||
|
allow(File).to receive(:delete)
|
||||||
|
allow(Rails.logger).to receive(:info)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calls the import service with correct parameters' do
|
||||||
|
expect(Users::ImportData).to receive(:new).with(user, anything)
|
||||||
|
|
||||||
|
job.perform(import.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calls import on the service' do
|
||||||
|
import_service = instance_double(Users::ImportData)
|
||||||
|
allow(Users::ImportData).to receive(:new).and_return(import_service)
|
||||||
|
expect(import_service).to receive(:import)
|
||||||
|
|
||||||
|
job.perform(import.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'completes successfully without updating import status' do
|
||||||
|
expect(import).not_to receive(:update!)
|
||||||
|
|
||||||
|
job.perform(import.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not create error notifications when successful' do
|
||||||
|
expect(::Notifications::Create).not_to receive(:new)
|
||||||
|
|
||||||
|
job.perform(import.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when import fails' do
|
||||||
|
let(:error_message) { 'Import failed due to invalid archive' }
|
||||||
|
let(:error) { StandardError.new(error_message) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Mock the import service to raise an error
|
||||||
|
import_service = instance_double(Users::ImportData)
|
||||||
|
allow(Users::ImportData).to receive(:new).and_return(import_service)
|
||||||
|
allow(import_service).to receive(:import).and_raise(error)
|
||||||
|
|
||||||
|
# Mock notification creation
|
||||||
|
notification_service = instance_double(::Notifications::Create, call: true)
|
||||||
|
allow(::Notifications::Create).to receive(:new).and_return(notification_service)
|
||||||
|
|
||||||
|
# Mock file operations
|
||||||
|
allow(File).to receive(:exist?).and_return(true)
|
||||||
|
allow(File).to receive(:delete)
|
||||||
|
allow(Rails.logger).to receive(:info)
|
||||||
|
|
||||||
|
# Mock ExceptionReporter
|
||||||
|
allow(ExceptionReporter).to receive(:call)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reports the error to ExceptionReporter' do
|
||||||
|
expect(ExceptionReporter).to receive(:call).with(error, "Import job failed for user #{user.id}")
|
||||||
|
|
||||||
|
expect { job.perform(import.id) }.to raise_error(StandardError, error_message)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not update import status on failure' do
|
||||||
|
expect(import).not_to receive(:update!)
|
||||||
|
|
||||||
|
expect { job.perform(import.id) }.to raise_error(StandardError, error_message)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a failure notification for the user' do
|
||||||
|
expect(::Notifications::Create).to receive(:new).with(
|
||||||
|
user: user,
|
||||||
|
title: 'Data import failed',
|
||||||
|
content: "Your data import failed with error: #{error_message}. Please check the archive format and try again.",
|
||||||
|
kind: :error
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { job.perform(import.id) }.to raise_error(StandardError, error_message)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 're-raises the error' do
|
||||||
|
expect { job.perform(import.id) }.to raise_error(StandardError, error_message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when import does not exist' do
|
||||||
|
let(:non_existent_import_id) { 999999 }
|
||||||
|
|
||||||
|
it 'raises ActiveRecord::RecordNotFound' do
|
||||||
|
expect { job.perform(non_existent_import_id) }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not create a notification when import is not found' do
|
||||||
|
expect(::Notifications::Create).not_to receive(:new)
|
||||||
|
|
||||||
|
expect { job.perform(non_existent_import_id) }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when archive file download fails' do
|
||||||
|
let(:error_message) { 'File download error' }
|
||||||
|
let(:error) { StandardError.new(error_message) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Mock file download to fail
|
||||||
|
allow(import).to receive(:file).and_return(
|
||||||
|
double('ActiveStorage::Attached::One', download: proc { raise error })
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock notification creation
|
||||||
|
notification_service = instance_double(::Notifications::Create, call: true)
|
||||||
|
allow(::Notifications::Create).to receive(:new).and_return(notification_service)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates notification with the correct user object' do
|
||||||
|
notification_service = instance_double(::Notifications::Create, call: true)
|
||||||
|
expect(::Notifications::Create).to receive(:new).with(
|
||||||
|
user: user,
|
||||||
|
title: 'Data import failed',
|
||||||
|
content: a_string_matching(/Your data import failed with error:.*Please check the archive format and try again\./),
|
||||||
|
kind: :error
|
||||||
|
).and_return(notification_service)
|
||||||
|
|
||||||
|
expect(notification_service).to receive(:call)
|
||||||
|
|
||||||
|
expect { job.perform(import.id) }.to raise_error(StandardError)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'job configuration' do
|
||||||
|
it 'is queued in the imports queue' do
|
||||||
|
expect(described_class.queue_name).to eq('imports')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -25,7 +25,8 @@ RSpec.describe Import, type: :model do
|
||||||
gpx: 4,
|
gpx: 4,
|
||||||
immich_api: 5,
|
immich_api: 5,
|
||||||
geojson: 6,
|
geojson: 6,
|
||||||
photoprism_api: 7
|
photoprism_api: 7,
|
||||||
|
user_data_archive: 8
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ RSpec.describe Users::ExportData::Points, type: :service do
|
||||||
course: 45.5,
|
course: 45.5,
|
||||||
course_accuracy: 2.5,
|
course_accuracy: 2.5,
|
||||||
external_track_id: 'ext-123',
|
external_track_id: 'ext-123',
|
||||||
|
longitude: -74.006,
|
||||||
|
latitude: 40.7128,
|
||||||
lonlat: 'POINT(-74.006 40.7128)'
|
lonlat: 'POINT(-74.006 40.7128)'
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -57,6 +59,8 @@ RSpec.describe Users::ExportData::Points, type: :service do
|
||||||
create(:point,
|
create(:point,
|
||||||
user: user,
|
user: user,
|
||||||
timestamp: 1640995260,
|
timestamp: 1640995260,
|
||||||
|
longitude: -73.9857,
|
||||||
|
latitude: 40.7484,
|
||||||
lonlat: 'POINT(-73.9857 40.7484)'
|
lonlat: 'POINT(-73.9857 40.7484)'
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -211,5 +215,54 @@ RSpec.describe Users::ExportData::Points, type: :service do
|
||||||
expect(subject.size).to eq(3)
|
expect(subject.size).to eq(3)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when points have missing coordinate data' do
|
||||||
|
let!(:point_with_lonlat_only) do
|
||||||
|
# Point with lonlat but missing individual coordinates
|
||||||
|
point = create(:point, user: user, lonlat: 'POINT(10.0 50.0)', external_track_id: 'lonlat-only')
|
||||||
|
# Clear individual coordinate fields to simulate legacy data
|
||||||
|
point.update_columns(longitude: nil, latitude: nil)
|
||||||
|
point
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:point_with_coordinates_only) do
|
||||||
|
# Point with coordinates but missing lonlat
|
||||||
|
point = create(:point, user: user, longitude: 15.0, latitude: 55.0, external_track_id: 'coords-only')
|
||||||
|
# Clear lonlat field to simulate missing geometry
|
||||||
|
point.update_columns(lonlat: nil)
|
||||||
|
point
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:point_without_coordinates) do
|
||||||
|
# Point with no coordinate data at all
|
||||||
|
point = create(:point, user: user, external_track_id: 'no-coords')
|
||||||
|
point.update_columns(longitude: nil, latitude: nil, lonlat: nil)
|
||||||
|
point
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes all coordinate fields for points with lonlat only' do
|
||||||
|
point_data = subject.find { |p| p['external_track_id'] == 'lonlat-only' }
|
||||||
|
|
||||||
|
expect(point_data).to be_present
|
||||||
|
expect(point_data['lonlat']).to be_present
|
||||||
|
expect(point_data['longitude']).to eq(10.0)
|
||||||
|
expect(point_data['latitude']).to eq(50.0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes all coordinate fields for points with coordinates only' do
|
||||||
|
point_data = subject.find { |p| p['external_track_id'] == 'coords-only' }
|
||||||
|
|
||||||
|
expect(point_data).to be_present
|
||||||
|
expect(point_data['lonlat']).to eq('POINT(15.0 55.0)')
|
||||||
|
expect(point_data['longitude']).to eq(15.0)
|
||||||
|
expect(point_data['latitude']).to eq(55.0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips points without any coordinate data' do
|
||||||
|
point_data = subject.find { |p| p['external_track_id'] == 'no-coords' }
|
||||||
|
|
||||||
|
expect(point_data).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,18 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
# Mock user settings
|
# Mock user settings
|
||||||
allow(user).to receive(:safe_settings).and_return(double(settings: { theme: 'dark' }))
|
allow(user).to receive(:safe_settings).and_return(double(settings: { theme: 'dark' }))
|
||||||
|
|
||||||
|
# Mock user associations for counting (needed before error occurs)
|
||||||
|
allow(user).to receive(:areas).and_return(double(count: 5))
|
||||||
|
allow(user).to receive(:imports).and_return(double(count: 12))
|
||||||
|
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||||
|
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||||
|
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||||
|
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
||||||
|
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||||
|
allow(user).to receive(:places).and_return(double(count: 20))
|
||||||
|
|
||||||
# Mock Export creation and file attachment
|
# Mock Export creation and file attachment
|
||||||
exports_double = double('Exports')
|
exports_double = double('Exports', count: 3)
|
||||||
allow(user).to receive(:exports).and_return(exports_double)
|
allow(user).to receive(:exports).and_return(exports_double)
|
||||||
allow(exports_double).to receive(:create!).and_return(export_record)
|
allow(exports_double).to receive(:create!).and_return(export_record)
|
||||||
allow(export_record).to receive(:update!)
|
allow(export_record).to receive(:update!)
|
||||||
|
|
@ -137,6 +147,22 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
result = service.export
|
result = service.export
|
||||||
expect(result).to eq(export_record)
|
expect(result).to eq(export_record)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'calculates entity counts correctly' do
|
||||||
|
counts = service.send(:calculate_entity_counts)
|
||||||
|
|
||||||
|
expect(counts).to eq({
|
||||||
|
areas: 5,
|
||||||
|
imports: 12,
|
||||||
|
exports: 3,
|
||||||
|
trips: 8,
|
||||||
|
stats: 24,
|
||||||
|
notifications: 10,
|
||||||
|
points: 15000,
|
||||||
|
visits: 45,
|
||||||
|
places: 20
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when an error occurs during export' do
|
context 'when an error occurs during export' do
|
||||||
|
|
@ -145,7 +171,7 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
|
|
||||||
before do
|
before do
|
||||||
# Mock Export creation first
|
# Mock Export creation first
|
||||||
exports_double = double('Exports')
|
exports_double = double('Exports', count: 3)
|
||||||
allow(user).to receive(:exports).and_return(exports_double)
|
allow(user).to receive(:exports).and_return(exports_double)
|
||||||
allow(exports_double).to receive(:create!).and_return(export_record)
|
allow(exports_double).to receive(:create!).and_return(export_record)
|
||||||
allow(export_record).to receive(:update!)
|
allow(export_record).to receive(:update!)
|
||||||
|
|
@ -153,10 +179,21 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
# Mock user settings and other dependencies that are needed before the error
|
# Mock user settings and other dependencies that are needed before the error
|
||||||
allow(user).to receive(:safe_settings).and_return(double(settings: { theme: 'dark' }))
|
allow(user).to receive(:safe_settings).and_return(double(settings: { theme: 'dark' }))
|
||||||
|
|
||||||
|
# Mock user associations for counting
|
||||||
|
allow(user).to receive(:areas).and_return(double(count: 5))
|
||||||
|
allow(user).to receive(:imports).and_return(double(count: 12))
|
||||||
|
# exports already mocked above
|
||||||
|
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||||
|
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||||
|
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||||
|
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
||||||
|
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||||
|
allow(user).to receive(:places).and_return(double(count: 20))
|
||||||
|
|
||||||
# Then set up the error condition - make it happen during the JSON writing step
|
# Then set up the error condition - make it happen during the JSON writing step
|
||||||
allow(File).to receive(:open).with(export_directory.join('data.json'), 'w').and_raise(StandardError, error_message)
|
allow(File).to receive(:open).with(export_directory.join('data.json'), 'w').and_raise(StandardError, error_message)
|
||||||
|
|
||||||
allow(Rails.logger).to receive(:error)
|
allow(ExceptionReporter).to receive(:call)
|
||||||
|
|
||||||
# Mock cleanup method and pathname existence
|
# Mock cleanup method and pathname existence
|
||||||
allow(service).to receive(:cleanup_temporary_files)
|
allow(service).to receive(:cleanup_temporary_files)
|
||||||
|
|
@ -169,8 +206,8 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
expect { service.export }.to raise_error(StandardError, error_message)
|
expect { service.export }.to raise_error(StandardError, error_message)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'logs the error' do
|
it 'reports the error via ExceptionReporter' do
|
||||||
expect(Rails.logger).to receive(:error).with("Export failed: #{error_message}")
|
expect(ExceptionReporter).to receive(:call).with(an_instance_of(StandardError), 'Export failed')
|
||||||
|
|
||||||
expect { service.export }.to raise_error(StandardError, error_message)
|
expect { service.export }.to raise_error(StandardError, error_message)
|
||||||
end
|
end
|
||||||
|
|
@ -188,7 +225,7 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
|
|
||||||
context 'when export record creation fails' do
|
context 'when export record creation fails' do
|
||||||
before do
|
before do
|
||||||
exports_double = double('Exports')
|
exports_double = double('Exports', count: 3)
|
||||||
allow(user).to receive(:exports).and_return(exports_double)
|
allow(user).to receive(:exports).and_return(exports_double)
|
||||||
allow(exports_double).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
|
allow(exports_double).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
|
||||||
end
|
end
|
||||||
|
|
@ -203,7 +240,7 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
|
|
||||||
before do
|
before do
|
||||||
# Mock Export creation
|
# Mock Export creation
|
||||||
exports_double = double('Exports')
|
exports_double = double('Exports', count: 3)
|
||||||
allow(user).to receive(:exports).and_return(exports_double)
|
allow(user).to receive(:exports).and_return(exports_double)
|
||||||
allow(exports_double).to receive(:create!).and_return(export_record)
|
allow(exports_double).to receive(:create!).and_return(export_record)
|
||||||
allow(export_record).to receive(:update!)
|
allow(export_record).to receive(:update!)
|
||||||
|
|
@ -221,6 +258,18 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
allow(Users::ExportData::Places).to receive(:new).and_return(double(call: []))
|
allow(Users::ExportData::Places).to receive(:new).and_return(double(call: []))
|
||||||
|
|
||||||
allow(user).to receive(:safe_settings).and_return(double(settings: {}))
|
allow(user).to receive(:safe_settings).and_return(double(settings: {}))
|
||||||
|
|
||||||
|
# Mock user associations for counting
|
||||||
|
allow(user).to receive(:areas).and_return(double(count: 5))
|
||||||
|
allow(user).to receive(:imports).and_return(double(count: 12))
|
||||||
|
# exports already mocked above
|
||||||
|
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||||
|
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||||
|
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||||
|
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
||||||
|
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||||
|
allow(user).to receive(:places).and_return(double(count: 20))
|
||||||
|
|
||||||
allow(File).to receive(:open).and_call_original
|
allow(File).to receive(:open).and_call_original
|
||||||
allow(File).to receive(:open).with(export_directory.join('data.json'), 'w').and_yield(StringIO.new)
|
allow(File).to receive(:open).with(export_directory.join('data.json'), 'w').and_yield(StringIO.new)
|
||||||
|
|
||||||
|
|
@ -292,11 +341,11 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
before do
|
before do
|
||||||
allow(File).to receive(:directory?).and_return(true)
|
allow(File).to receive(:directory?).and_return(true)
|
||||||
allow(FileUtils).to receive(:rm_rf).and_raise(StandardError, 'Permission denied')
|
allow(FileUtils).to receive(:rm_rf).and_raise(StandardError, 'Permission denied')
|
||||||
allow(Rails.logger).to receive(:error)
|
allow(ExceptionReporter).to receive(:call)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'logs the error but does not re-raise' do
|
it 'reports the error via ExceptionReporter but does not re-raise' do
|
||||||
expect(Rails.logger).to receive(:error).with('Failed to cleanup temporary files: Permission denied')
|
expect(ExceptionReporter).to receive(:call).with(an_instance_of(StandardError), 'Failed to cleanup temporary files')
|
||||||
|
|
||||||
expect { service.send(:cleanup_temporary_files, export_directory) }.not_to raise_error
|
expect { service.send(:cleanup_temporary_files, export_directory) }.not_to raise_error
|
||||||
end
|
end
|
||||||
|
|
@ -314,5 +363,44 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#calculate_entity_counts' do
|
||||||
|
before do
|
||||||
|
# Mock user associations for counting
|
||||||
|
allow(user).to receive(:areas).and_return(double(count: 5))
|
||||||
|
allow(user).to receive(:imports).and_return(double(count: 12))
|
||||||
|
allow(user).to receive(:exports).and_return(double(count: 3))
|
||||||
|
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||||
|
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||||
|
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||||
|
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
||||||
|
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||||
|
allow(user).to receive(:places).and_return(double(count: 20))
|
||||||
|
allow(Rails.logger).to receive(:info)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns correct counts for all entity types' do
|
||||||
|
counts = service.send(:calculate_entity_counts)
|
||||||
|
|
||||||
|
expect(counts).to eq({
|
||||||
|
areas: 5,
|
||||||
|
imports: 12,
|
||||||
|
exports: 3,
|
||||||
|
trips: 8,
|
||||||
|
stats: 24,
|
||||||
|
notifications: 10,
|
||||||
|
points: 15000,
|
||||||
|
visits: 45,
|
||||||
|
places: 20
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the calculation process' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Calculating entity counts for export")
|
||||||
|
expect(Rails.logger).to receive(:info).with(/Entity counts:/)
|
||||||
|
|
||||||
|
service.send(:calculate_entity_counts)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
161
spec/services/users/import_data/areas_spec.rb
Normal file
161
spec/services/users/import_data/areas_spec.rb
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::ImportData::Areas, type: :service do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:areas_data) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'name' => 'Home',
|
||||||
|
'latitude' => '40.7128',
|
||||||
|
'longitude' => '-74.0060',
|
||||||
|
'radius' => 100,
|
||||||
|
'created_at' => '2024-01-01T00:00:00Z',
|
||||||
|
'updated_at' => '2024-01-01T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name' => 'Work',
|
||||||
|
'latitude' => '40.7589',
|
||||||
|
'longitude' => '-73.9851',
|
||||||
|
'radius' => 50,
|
||||||
|
'created_at' => '2024-01-02T00:00:00Z',
|
||||||
|
'updated_at' => '2024-01-02T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
let(:service) { described_class.new(user, areas_data) }
|
||||||
|
|
||||||
|
describe '#call' do
|
||||||
|
context 'with valid areas data' do
|
||||||
|
it 'creates new areas for the user' do
|
||||||
|
expect { service.call }.to change { user.areas.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates areas with correct attributes' do
|
||||||
|
service.call
|
||||||
|
|
||||||
|
home_area = user.areas.find_by(name: 'Home')
|
||||||
|
expect(home_area).to have_attributes(
|
||||||
|
name: 'Home',
|
||||||
|
latitude: 40.7128,
|
||||||
|
longitude: -74.0060,
|
||||||
|
radius: 100
|
||||||
|
)
|
||||||
|
|
||||||
|
work_area = user.areas.find_by(name: 'Work')
|
||||||
|
expect(work_area).to have_attributes(
|
||||||
|
name: 'Work',
|
||||||
|
latitude: 40.7589,
|
||||||
|
longitude: -73.9851,
|
||||||
|
radius: 50
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the number of areas created' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 2 areas for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Areas import completed. Created: 2")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with duplicate areas' do
|
||||||
|
before do
|
||||||
|
# Create an existing area with same name and coordinates
|
||||||
|
user.areas.create!(
|
||||||
|
name: 'Home',
|
||||||
|
latitude: 40.7128,
|
||||||
|
longitude: -74.0060,
|
||||||
|
radius: 100
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips duplicate areas' do
|
||||||
|
expect { service.call }.to change { user.areas.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs when skipping duplicates' do
|
||||||
|
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||||
|
expect(Rails.logger).to receive(:debug).with("Area already exists: Home")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns only the count of newly created areas' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid area data' do
|
||||||
|
let(:areas_data) do
|
||||||
|
[
|
||||||
|
{ 'name' => 'Valid Area', 'latitude' => '40.7128', 'longitude' => '-74.0060', 'radius' => 100 },
|
||||||
|
'invalid_data',
|
||||||
|
{ 'name' => 'Another Valid Area', 'latitude' => '40.7589', 'longitude' => '-73.9851', 'radius' => 50 }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips invalid entries and imports valid ones' do
|
||||||
|
expect { service.call }.to change { user.areas.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the count of valid areas created' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with nil areas data' do
|
||||||
|
let(:areas_data) { nil }
|
||||||
|
|
||||||
|
it 'does not create any areas' do
|
||||||
|
expect { service.call }.not_to change { user.areas.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with non-array areas data' do
|
||||||
|
let(:areas_data) { 'invalid_data' }
|
||||||
|
|
||||||
|
it 'does not create any areas' do
|
||||||
|
expect { service.call }.not_to change { user.areas.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with empty areas data' do
|
||||||
|
let(:areas_data) { [] }
|
||||||
|
|
||||||
|
it 'does not create any areas' do
|
||||||
|
expect { service.call }.not_to change { user.areas.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process with 0 count' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 0 areas for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Areas import completed. Created: 0")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
270
spec/services/users/import_data/imports_spec.rb
Normal file
270
spec/services/users/import_data/imports_spec.rb
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::ImportData::Imports, type: :service do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:files_directory) { Rails.root.join('tmp', 'test_files') }
|
||||||
|
let(:imports_data) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'name' => '2023_MARCH.json',
|
||||||
|
'source' => 'google_semantic_history',
|
||||||
|
'created_at' => '2024-01-01T00:00:00Z',
|
||||||
|
'updated_at' => '2024-01-01T00:00:00Z',
|
||||||
|
'processed' => true,
|
||||||
|
'file_name' => 'import_1_2023_MARCH.json',
|
||||||
|
'original_filename' => '2023_MARCH.json',
|
||||||
|
'file_size' => 2048576,
|
||||||
|
'content_type' => 'application/json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name' => '2023_APRIL.json',
|
||||||
|
'source' => 'owntracks',
|
||||||
|
'created_at' => '2024-01-02T00:00:00Z',
|
||||||
|
'updated_at' => '2024-01-02T00:00:00Z',
|
||||||
|
'processed' => false,
|
||||||
|
'file_name' => 'import_2_2023_APRIL.json',
|
||||||
|
'original_filename' => '2023_APRIL.json',
|
||||||
|
'file_size' => 1048576,
|
||||||
|
'content_type' => 'application/json'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
let(:service) { described_class.new(user, imports_data, files_directory) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
FileUtils.mkdir_p(files_directory)
|
||||||
|
# Create mock files
|
||||||
|
File.write(files_directory.join('import_1_2023_MARCH.json'), '{"test": "data"}')
|
||||||
|
File.write(files_directory.join('import_2_2023_APRIL.json'), '{"more": "data"}')
|
||||||
|
|
||||||
|
# Mock the Import job to prevent it from being enqueued
|
||||||
|
allow(Import::ProcessJob).to receive(:perform_later)
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
FileUtils.rm_rf(files_directory) if files_directory.exist?
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#call' do
|
||||||
|
context 'with valid imports data' do
|
||||||
|
it 'creates new imports for the user' do
|
||||||
|
expect { service.call }.to change { user.imports.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates imports with correct attributes' do
|
||||||
|
service.call
|
||||||
|
|
||||||
|
march_import = user.imports.find_by(name: '2023_MARCH.json')
|
||||||
|
expect(march_import).to have_attributes(
|
||||||
|
name: '2023_MARCH.json',
|
||||||
|
source: 'google_semantic_history',
|
||||||
|
processed: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
april_import = user.imports.find_by(name: '2023_APRIL.json')
|
||||||
|
expect(april_import).to have_attributes(
|
||||||
|
name: '2023_APRIL.json',
|
||||||
|
source: 'owntracks',
|
||||||
|
processed: 0
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'attaches files to the imports' do
|
||||||
|
service.call
|
||||||
|
|
||||||
|
march_import = user.imports.find_by(name: '2023_MARCH.json')
|
||||||
|
expect(march_import.file).to be_attached
|
||||||
|
expect(march_import.file.filename.to_s).to eq('2023_MARCH.json')
|
||||||
|
expect(march_import.file.content_type).to eq('application/json')
|
||||||
|
|
||||||
|
april_import = user.imports.find_by(name: '2023_APRIL.json')
|
||||||
|
expect(april_import.file).to be_attached
|
||||||
|
expect(april_import.file.filename.to_s).to eq('2023_APRIL.json')
|
||||||
|
expect(april_import.file.content_type).to eq('application/json')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the number of imports and files created' do
|
||||||
|
imports_created, files_restored = service.call
|
||||||
|
expect(imports_created).to eq(2)
|
||||||
|
expect(files_restored).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process' do
|
||||||
|
allow(Rails.logger).to receive(:info) # Allow all info logs (including ActiveStorage)
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 2 imports for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Imports import completed. Created: 2, Files restored: 2")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with duplicate imports' do
|
||||||
|
before do
|
||||||
|
# Create an existing import with same name, source, and created_at
|
||||||
|
user.imports.create!(
|
||||||
|
name: '2023_MARCH.json',
|
||||||
|
source: 'google_semantic_history',
|
||||||
|
created_at: Time.parse('2024-01-01T00:00:00Z')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips duplicate imports' do
|
||||||
|
expect { service.call }.to change { user.imports.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs when skipping duplicates' do
|
||||||
|
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||||
|
expect(Rails.logger).to receive(:debug).with("Import already exists: 2023_MARCH.json")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns only the count of newly created imports' do
|
||||||
|
imports_created, files_restored = service.call
|
||||||
|
expect(imports_created).to eq(1)
|
||||||
|
expect(files_restored).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with missing files' do
|
||||||
|
before do
|
||||||
|
FileUtils.rm_f(files_directory.join('import_1_2023_MARCH.json'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates imports but logs file errors' do
|
||||||
|
expect(Rails.logger).to receive(:warn).with(/Import file not found/)
|
||||||
|
|
||||||
|
imports_created, files_restored = service.call
|
||||||
|
expect(imports_created).to eq(2)
|
||||||
|
expect(files_restored).to eq(1) # Only one file was successfully restored
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates imports without file attachments for missing files' do
|
||||||
|
service.call
|
||||||
|
|
||||||
|
march_import = user.imports.find_by(name: '2023_MARCH.json')
|
||||||
|
expect(march_import.file).not_to be_attached
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with imports that have no files (null file_name)' do
|
||||||
|
let(:imports_data) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'name' => 'No File Import',
|
||||||
|
'source' => 'gpx',
|
||||||
|
'created_at' => '2024-01-01T00:00:00Z',
|
||||||
|
'processed' => true,
|
||||||
|
'file_name' => nil,
|
||||||
|
'original_filename' => nil
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates imports without attempting file restoration' do
|
||||||
|
expect { service.call }.to change { user.imports.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns correct counts' do
|
||||||
|
imports_created, files_restored = service.call
|
||||||
|
expect(imports_created).to eq(1)
|
||||||
|
expect(files_restored).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid import data' do
|
||||||
|
let(:imports_data) do
|
||||||
|
[
|
||||||
|
{ 'name' => 'Valid Import', 'source' => 'owntracks' },
|
||||||
|
'invalid_data',
|
||||||
|
{ 'name' => 'Another Valid Import', 'source' => 'gpx' }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips invalid entries and imports valid ones' do
|
||||||
|
expect { service.call }.to change { user.imports.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the count of valid imports created' do
|
||||||
|
imports_created, files_restored = service.call
|
||||||
|
expect(imports_created).to eq(2)
|
||||||
|
expect(files_restored).to eq(0) # No files for these imports
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with validation errors' do
|
||||||
|
let(:imports_data) do
|
||||||
|
[
|
||||||
|
{ 'name' => 'Valid Import', 'source' => 'owntracks' },
|
||||||
|
{ 'source' => 'owntracks' }, # missing name
|
||||||
|
{ 'name' => 'Missing Source Import' } # missing source
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'only creates valid imports' do
|
||||||
|
expect { service.call }.to change { user.imports.count }.by(2)
|
||||||
|
|
||||||
|
# Verify only the valid imports were created (name is required, source defaults to first enum)
|
||||||
|
created_imports = user.imports.pluck(:name, :source)
|
||||||
|
expect(created_imports).to contain_exactly(
|
||||||
|
['Valid Import', 'owntracks'],
|
||||||
|
['Missing Source Import', 'google_semantic_history']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs validation errors' do
|
||||||
|
expect(Rails.logger).to receive(:error).at_least(:once)
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with nil imports data' do
|
||||||
|
let(:imports_data) { nil }
|
||||||
|
|
||||||
|
it 'does not create any imports' do
|
||||||
|
expect { service.call }.not_to change { user.imports.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns [0, 0]' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq([0, 0])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with non-array imports data' do
|
||||||
|
let(:imports_data) { 'invalid_data' }
|
||||||
|
|
||||||
|
it 'does not create any imports' do
|
||||||
|
expect { service.call }.not_to change { user.imports.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns [0, 0]' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq([0, 0])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with empty imports data' do
|
||||||
|
let(:imports_data) { [] }
|
||||||
|
|
||||||
|
it 'does not create any imports' do
|
||||||
|
expect { service.call }.not_to change { user.imports.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process with 0 count' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 0 imports for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Imports import completed. Created: 0, Files restored: 0")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns [0, 0]' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq([0, 0])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
181
spec/services/users/import_data/notifications_spec.rb
Normal file
181
spec/services/users/import_data/notifications_spec.rb
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::ImportData::Notifications, type: :service do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:notifications_data) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'kind' => 'info',
|
||||||
|
'title' => 'Import completed',
|
||||||
|
'content' => 'Your data import has been processed successfully',
|
||||||
|
'read_at' => '2024-01-01T12:30:00Z',
|
||||||
|
'created_at' => '2024-01-01T12:00:00Z',
|
||||||
|
'updated_at' => '2024-01-01T12:30:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'kind' => 'error',
|
||||||
|
'title' => 'Import failed',
|
||||||
|
'content' => 'There was an error processing your data',
|
||||||
|
'read_at' => nil,
|
||||||
|
'created_at' => '2024-01-02T10:00:00Z',
|
||||||
|
'updated_at' => '2024-01-02T10:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
let(:service) { described_class.new(user, notifications_data) }
|
||||||
|
|
||||||
|
describe '#call' do
|
||||||
|
context 'with valid notifications data' do
|
||||||
|
it 'creates new notifications for the user' do
|
||||||
|
expect { service.call }.to change { user.notifications.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates notifications with correct attributes' do
|
||||||
|
service.call
|
||||||
|
|
||||||
|
import_notification = user.notifications.find_by(title: 'Import completed')
|
||||||
|
expect(import_notification).to have_attributes(
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Import completed',
|
||||||
|
content: 'Your data import has been processed successfully',
|
||||||
|
read_at: Time.parse('2024-01-01T12:30:00Z')
|
||||||
|
)
|
||||||
|
|
||||||
|
error_notification = user.notifications.find_by(title: 'Import failed')
|
||||||
|
expect(error_notification).to have_attributes(
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Import failed',
|
||||||
|
content: 'There was an error processing your data',
|
||||||
|
read_at: nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the number of notifications created' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 2 notifications for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Notifications import completed. Created: 2")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with duplicate notifications' do
|
||||||
|
before do
|
||||||
|
# Create an existing notification with same title, content, and created_at
|
||||||
|
user.notifications.create!(
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Import completed',
|
||||||
|
content: 'Your data import has been processed successfully',
|
||||||
|
created_at: Time.parse('2024-01-01T12:00:00Z')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips duplicate notifications' do
|
||||||
|
expect { service.call }.to change { user.notifications.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs when skipping duplicates' do
|
||||||
|
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||||
|
expect(Rails.logger).to receive(:debug).with("Notification already exists: Import completed")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns only the count of newly created notifications' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid notification data' do
|
||||||
|
let(:notifications_data) do
|
||||||
|
[
|
||||||
|
{ 'kind' => 'info', 'title' => 'Valid Notification', 'content' => 'Valid content' },
|
||||||
|
'invalid_data',
|
||||||
|
{ 'kind' => 'error', 'title' => 'Another Valid Notification', 'content' => 'Another valid content' }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips invalid entries and imports valid ones' do
|
||||||
|
expect { service.call }.to change { user.notifications.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the count of valid notifications created' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with validation errors' do
|
||||||
|
let(:notifications_data) do
|
||||||
|
[
|
||||||
|
{ 'kind' => 'info', 'title' => 'Valid Notification', 'content' => 'Valid content' },
|
||||||
|
{ 'kind' => 'info', 'content' => 'Missing title' }, # missing title
|
||||||
|
{ 'kind' => 'error', 'title' => 'Missing content' } # missing content
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'only creates valid notifications' do
|
||||||
|
expect { service.call }.to change { user.notifications.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs validation errors' do
|
||||||
|
expect(Rails.logger).to receive(:error).at_least(:once)
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with nil notifications data' do
|
||||||
|
let(:notifications_data) { nil }
|
||||||
|
|
||||||
|
it 'does not create any notifications' do
|
||||||
|
expect { service.call }.not_to change { user.notifications.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with non-array notifications data' do
|
||||||
|
let(:notifications_data) { 'invalid_data' }
|
||||||
|
|
||||||
|
it 'does not create any notifications' do
|
||||||
|
expect { service.call }.not_to change { user.notifications.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with empty notifications data' do
|
||||||
|
let(:notifications_data) { [] }
|
||||||
|
|
||||||
|
it 'does not create any notifications' do
|
||||||
|
expect { service.call }.not_to change { user.notifications.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process with 0 count' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 0 notifications for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Notifications import completed. Created: 0")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
216
spec/services/users/import_data/places_spec.rb
Normal file
216
spec/services/users/import_data/places_spec.rb
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::ImportData::Places, type: :service do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:places_data) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'name' => 'Home',
|
||||||
|
'latitude' => '40.7128',
|
||||||
|
'longitude' => '-74.0060',
|
||||||
|
'source' => 'manual',
|
||||||
|
'geodata' => { 'address' => '123 Main St' },
|
||||||
|
'created_at' => '2024-01-01T00:00:00Z',
|
||||||
|
'updated_at' => '2024-01-01T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name' => 'Office',
|
||||||
|
'latitude' => '40.7589',
|
||||||
|
'longitude' => '-73.9851',
|
||||||
|
'source' => 'photon',
|
||||||
|
'geodata' => { 'properties' => { 'name' => 'Office Building' } },
|
||||||
|
'created_at' => '2024-01-02T00:00:00Z',
|
||||||
|
'updated_at' => '2024-01-02T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
let(:service) { described_class.new(user, places_data) }
|
||||||
|
|
||||||
|
describe '#call' do
|
||||||
|
context 'with valid places data' do
|
||||||
|
it 'creates new places' do
|
||||||
|
expect { service.call }.to change { Place.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates places with correct attributes' do
|
||||||
|
service.call
|
||||||
|
|
||||||
|
home_place = Place.find_by(name: 'Home')
|
||||||
|
expect(home_place).to have_attributes(
|
||||||
|
name: 'Home',
|
||||||
|
source: 'manual'
|
||||||
|
)
|
||||||
|
expect(home_place.lat).to be_within(0.0001).of(40.7128)
|
||||||
|
expect(home_place.lon).to be_within(0.0001).of(-74.0060)
|
||||||
|
expect(home_place.geodata).to eq('address' => '123 Main St')
|
||||||
|
|
||||||
|
office_place = Place.find_by(name: 'Office')
|
||||||
|
expect(office_place).to have_attributes(
|
||||||
|
name: 'Office',
|
||||||
|
source: 'photon'
|
||||||
|
)
|
||||||
|
expect(office_place.lat).to be_within(0.0001).of(40.7589)
|
||||||
|
expect(office_place.lon).to be_within(0.0001).of(-73.9851)
|
||||||
|
expect(office_place.geodata).to eq('properties' => { 'name' => 'Office Building' })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the number of places created' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 2 places for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Places import completed. Created: 2")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with duplicate places (same name)' do
|
||||||
|
before do
|
||||||
|
# Create an existing place with same name
|
||||||
|
create(:place, name: 'Home',
|
||||||
|
latitude: 40.7128, longitude: -74.0060,
|
||||||
|
lonlat: 'POINT(-74.0060 40.7128)')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips duplicate places' do
|
||||||
|
expect { service.call }.to change { Place.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs when skipping duplicates' do
|
||||||
|
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||||
|
expect(Rails.logger).to receive(:debug).with("Place already exists: Home")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns only the count of newly created places' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with duplicate places (same coordinates)' do
|
||||||
|
before do
|
||||||
|
# Create an existing place with same coordinates but different name
|
||||||
|
create(:place, name: 'Different Name',
|
||||||
|
latitude: 40.7128, longitude: -74.0060,
|
||||||
|
lonlat: 'POINT(-74.0060 40.7128)')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips duplicate places by coordinates' do
|
||||||
|
expect { service.call }.to change { Place.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs when skipping duplicates' do
|
||||||
|
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||||
|
expect(Rails.logger).to receive(:debug).with("Place already exists: Home")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with places having same name but different coordinates' do
|
||||||
|
before do
|
||||||
|
create(:place, name: 'Different Place',
|
||||||
|
latitude: 41.0000, longitude: -75.0000,
|
||||||
|
lonlat: 'POINT(-75.0000 41.0000)')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates both places since coordinates and names differ' do
|
||||||
|
expect { service.call }.to change { Place.count }.by(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid place data' do
|
||||||
|
let(:places_data) do
|
||||||
|
[
|
||||||
|
{ 'name' => 'Valid Place', 'latitude' => '40.7128', 'longitude' => '-74.0060' },
|
||||||
|
'invalid_data',
|
||||||
|
{ 'name' => 'Another Valid Place', 'latitude' => '40.7589', 'longitude' => '-73.9851' }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips invalid entries and imports valid ones' do
|
||||||
|
expect { service.call }.to change { Place.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the count of valid places created' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with missing required fields' do
|
||||||
|
let(:places_data) do
|
||||||
|
[
|
||||||
|
{ 'name' => 'Valid Place', 'latitude' => '40.7128', 'longitude' => '-74.0060' },
|
||||||
|
{ 'latitude' => '40.7589', 'longitude' => '-73.9851' }, # missing name
|
||||||
|
{ 'name' => 'Invalid Place', 'longitude' => '-73.9851' }, # missing latitude
|
||||||
|
{ 'name' => 'Another Invalid Place', 'latitude' => '40.7589' } # missing longitude
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'only creates places with all required fields' do
|
||||||
|
expect { service.call }.to change { Place.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs skipped records with missing data' do
|
||||||
|
allow(Rails.logger).to receive(:debug) # Allow all debug logs
|
||||||
|
expect(Rails.logger).to receive(:debug).with(/Skipping place with missing required data/).at_least(:once)
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with nil places data' do
|
||||||
|
let(:places_data) { nil }
|
||||||
|
|
||||||
|
it 'does not create any places' do
|
||||||
|
expect { service.call }.not_to change { Place.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with non-array places data' do
|
||||||
|
let(:places_data) { 'invalid_data' }
|
||||||
|
|
||||||
|
it 'does not create any places' do
|
||||||
|
expect { service.call }.not_to change { Place.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with empty places data' do
|
||||||
|
let(:places_data) { [] }
|
||||||
|
|
||||||
|
it 'does not create any places' do
|
||||||
|
expect { service.call }.not_to change { Place.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process with 0 count' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 0 places for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Places import completed. Created: 0")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
139
spec/services/users/import_data/points_spec.rb
Normal file
139
spec/services/users/import_data/points_spec.rb
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::ImportData::Points, type: :service do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:service) { described_class.new(user, points_data) }
|
||||||
|
|
||||||
|
describe '#call' do
|
||||||
|
context 'when importing points with country information' do
|
||||||
|
let(:country) { create(:country, name: 'Germany', iso_a2: 'DE', iso_a3: 'DEU') }
|
||||||
|
let(:points_data) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'timestamp' => 1640995200,
|
||||||
|
'lonlat' => 'POINT(13.4050 52.5200)',
|
||||||
|
'city' => 'Berlin',
|
||||||
|
'country' => 'Germany', # String field from export
|
||||||
|
'country_info' => {
|
||||||
|
'name' => 'Germany',
|
||||||
|
'iso_a2' => 'DE',
|
||||||
|
'iso_a3' => 'DEU'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
country # Create the country
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates points without type errors' do
|
||||||
|
expect { service.call }.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'assigns the correct country association' do
|
||||||
|
service.call
|
||||||
|
point = user.tracked_points.last
|
||||||
|
expect(point.country).to eq(country)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes the string country field from attributes' do
|
||||||
|
service.call
|
||||||
|
point = user.tracked_points.last
|
||||||
|
# The country association should be set, not the string attribute
|
||||||
|
expect(point.read_attribute(:country)).to be_nil
|
||||||
|
expect(point.country).to eq(country)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when country does not exist in database' do
|
||||||
|
let(:points_data) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'timestamp' => 1640995200,
|
||||||
|
'lonlat' => 'POINT(13.4050 52.5200)',
|
||||||
|
'city' => 'Berlin',
|
||||||
|
'country' => 'NewCountry',
|
||||||
|
'country_info' => {
|
||||||
|
'name' => 'NewCountry',
|
||||||
|
'iso_a2' => 'NC',
|
||||||
|
'iso_a3' => 'NCO'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates the country and assigns it' do
|
||||||
|
expect { service.call }.to change(Country, :count).by(1)
|
||||||
|
|
||||||
|
point = user.tracked_points.last
|
||||||
|
expect(point.country.name).to eq('NewCountry')
|
||||||
|
expect(point.country.iso_a2).to eq('NC')
|
||||||
|
expect(point.country.iso_a3).to eq('NCO')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when points_data is empty' do
|
||||||
|
let(:points_data) { [] }
|
||||||
|
|
||||||
|
it 'returns 0 without errors' do
|
||||||
|
expect(service.call).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when points_data is not an array' do
|
||||||
|
let(:points_data) { 'invalid' }
|
||||||
|
|
||||||
|
it 'returns 0 without errors' do
|
||||||
|
expect(service.call).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when points have invalid or missing data' do
|
||||||
|
let(:points_data) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'timestamp' => 1640995200,
|
||||||
|
'lonlat' => 'POINT(13.4050 52.5200)',
|
||||||
|
'city' => 'Berlin'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# Missing lonlat but has longitude/latitude (should be reconstructed)
|
||||||
|
'timestamp' => 1640995220,
|
||||||
|
'longitude' => 11.5820,
|
||||||
|
'latitude' => 48.1351,
|
||||||
|
'city' => 'Munich'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# Missing lonlat and coordinates
|
||||||
|
'timestamp' => 1640995260,
|
||||||
|
'city' => 'Hamburg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# Missing timestamp
|
||||||
|
'lonlat' => 'POINT(11.5820 48.1351)',
|
||||||
|
'city' => 'Stuttgart'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# Invalid lonlat format
|
||||||
|
'timestamp' => 1640995320,
|
||||||
|
'lonlat' => 'invalid format',
|
||||||
|
'city' => 'Frankfurt'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'imports valid points and reconstructs lonlat when needed' do
|
||||||
|
expect(service.call).to eq(2) # Two valid points (original + reconstructed)
|
||||||
|
expect(user.tracked_points.count).to eq(2)
|
||||||
|
|
||||||
|
# Check that lonlat was reconstructed properly
|
||||||
|
munich_point = user.tracked_points.find_by(city: 'Munich')
|
||||||
|
expect(munich_point).to be_present
|
||||||
|
expect(munich_point.lonlat.to_s).to match(/POINT\s*\(11\.582\s+48\.1351\)/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
82
spec/services/users/import_data/settings_spec.rb
Normal file
82
spec/services/users/import_data/settings_spec.rb
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::ImportData::Settings, type: :service do
|
||||||
|
let(:user) { create(:user, settings: { existing_setting: 'value', theme: 'light' }) }
|
||||||
|
let(:settings_data) { { 'theme' => 'dark', 'distance_unit' => 'km', 'new_setting' => 'test' } }
|
||||||
|
let(:service) { described_class.new(user, settings_data) }
|
||||||
|
|
||||||
|
describe '#call' do
|
||||||
|
context 'with valid settings data' do
|
||||||
|
it 'merges imported settings with existing settings' do
|
||||||
|
expect { service.call }.to change { user.reload.settings }.to(
|
||||||
|
'existing_setting' => 'value',
|
||||||
|
'theme' => 'dark',
|
||||||
|
'distance_unit' => 'km',
|
||||||
|
'new_setting' => 'test'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'gives precedence to imported settings over existing ones' do
|
||||||
|
service.call
|
||||||
|
|
||||||
|
expect(user.reload.settings['theme']).to eq('dark')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing settings for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Settings import completed")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with nil settings data' do
|
||||||
|
let(:settings_data) { nil }
|
||||||
|
|
||||||
|
it 'does not change user settings' do
|
||||||
|
expect { service.call }.not_to change { user.reload.settings }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not log import process' do
|
||||||
|
expect(Rails.logger).not_to receive(:info)
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with non-hash settings data' do
|
||||||
|
let(:settings_data) { 'invalid_data' }
|
||||||
|
|
||||||
|
it 'does not change user settings' do
|
||||||
|
expect { service.call }.not_to change { user.reload.settings }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not log import process' do
|
||||||
|
expect(Rails.logger).not_to receive(:info)
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with empty settings data' do
|
||||||
|
let(:settings_data) { {} }
|
||||||
|
|
||||||
|
it 'preserves existing settings without adding new ones' do
|
||||||
|
original_settings = user.settings.dup
|
||||||
|
|
||||||
|
service.call
|
||||||
|
|
||||||
|
expect(user.reload.settings).to eq(original_settings)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing settings for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Settings import completed")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
188
spec/services/users/import_data/stats_spec.rb
Normal file
188
spec/services/users/import_data/stats_spec.rb
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::ImportData::Stats, type: :service do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:stats_data) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'year' => 2024,
|
||||||
|
'month' => 1,
|
||||||
|
'distance' => 456.78,
|
||||||
|
'daily_distance' => [[1, 15.2], [2, 23.5], [3, 18.1]],
|
||||||
|
'toponyms' => [
|
||||||
|
{ 'country' => 'United States', 'cities' => [{ 'city' => 'New York' }] }
|
||||||
|
],
|
||||||
|
'created_at' => '2024-02-01T00:00:00Z',
|
||||||
|
'updated_at' => '2024-02-01T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'year' => 2024,
|
||||||
|
'month' => 2,
|
||||||
|
'distance' => 321.45,
|
||||||
|
'daily_distance' => [[1, 12.3], [2, 19.8], [3, 25.4]],
|
||||||
|
'toponyms' => [
|
||||||
|
{ 'country' => 'Canada', 'cities' => [{ 'city' => 'Toronto' }] }
|
||||||
|
],
|
||||||
|
'created_at' => '2024-03-01T00:00:00Z',
|
||||||
|
'updated_at' => '2024-03-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
let(:service) { described_class.new(user, stats_data) }
|
||||||
|
|
||||||
|
describe '#call' do
|
||||||
|
context 'with valid stats data' do
|
||||||
|
it 'creates new stats for the user' do
|
||||||
|
expect { service.call }.to change { user.stats.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates stats with correct attributes' do
|
||||||
|
service.call
|
||||||
|
|
||||||
|
jan_stats = user.stats.find_by(year: 2024, month: 1)
|
||||||
|
expect(jan_stats).to have_attributes(
|
||||||
|
year: 2024,
|
||||||
|
month: 1,
|
||||||
|
distance: 456
|
||||||
|
)
|
||||||
|
expect(jan_stats.daily_distance).to eq([[1, 15.2], [2, 23.5], [3, 18.1]])
|
||||||
|
expect(jan_stats.toponyms).to eq([{ 'country' => 'United States', 'cities' => [{ 'city' => 'New York' }] }])
|
||||||
|
|
||||||
|
feb_stats = user.stats.find_by(year: 2024, month: 2)
|
||||||
|
expect(feb_stats).to have_attributes(
|
||||||
|
year: 2024,
|
||||||
|
month: 2,
|
||||||
|
distance: 321
|
||||||
|
)
|
||||||
|
expect(feb_stats.daily_distance).to eq([[1, 12.3], [2, 19.8], [3, 25.4]])
|
||||||
|
expect(feb_stats.toponyms).to eq([{ 'country' => 'Canada', 'cities' => [{ 'city' => 'Toronto' }] }])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the number of stats created' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 2 stats for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Stats import completed. Created: 2")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with duplicate stats (same year and month)' do
|
||||||
|
before do
|
||||||
|
# Create an existing stat with same year and month
|
||||||
|
user.stats.create!(
|
||||||
|
year: 2024,
|
||||||
|
month: 1,
|
||||||
|
distance: 100.0
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips duplicate stats' do
|
||||||
|
expect { service.call }.to change { user.stats.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs when skipping duplicates' do
|
||||||
|
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||||
|
expect(Rails.logger).to receive(:debug).with("Stat already exists: 2024-1")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns only the count of newly created stats' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid stat data' do
|
||||||
|
let(:stats_data) do
|
||||||
|
[
|
||||||
|
{ 'year' => 2024, 'month' => 1, 'distance' => 456.78 },
|
||||||
|
'invalid_data',
|
||||||
|
{ 'year' => 2024, 'month' => 2, 'distance' => 321.45 }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips invalid entries and imports valid ones' do
|
||||||
|
expect { service.call }.to change { user.stats.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the count of valid stats created' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with validation errors' do
|
||||||
|
let(:stats_data) do
|
||||||
|
[
|
||||||
|
{ 'year' => 2024, 'month' => 1, 'distance' => 456.78 },
|
||||||
|
{ 'month' => 1, 'distance' => 321.45 }, # missing year
|
||||||
|
{ 'year' => 2024, 'distance' => 123.45 } # missing month
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'only creates valid stats' do
|
||||||
|
expect { service.call }.to change { user.stats.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs validation errors' do
|
||||||
|
expect(Rails.logger).to receive(:error).at_least(:once)
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with nil stats data' do
|
||||||
|
let(:stats_data) { nil }
|
||||||
|
|
||||||
|
it 'does not create any stats' do
|
||||||
|
expect { service.call }.not_to change { user.stats.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with non-array stats data' do
|
||||||
|
let(:stats_data) { 'invalid_data' }
|
||||||
|
|
||||||
|
it 'does not create any stats' do
|
||||||
|
expect { service.call }.not_to change { user.stats.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with empty stats data' do
|
||||||
|
let(:stats_data) { [] }
|
||||||
|
|
||||||
|
it 'does not create any stats' do
|
||||||
|
expect { service.call }.not_to change { user.stats.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process with 0 count' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 0 stats for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Stats import completed. Created: 0")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
186
spec/services/users/import_data/trips_spec.rb
Normal file
186
spec/services/users/import_data/trips_spec.rb
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::ImportData::Trips, type: :service do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:trips_data) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'name' => 'Business Trip to NYC',
|
||||||
|
'started_at' => '2024-01-15T08:00:00Z',
|
||||||
|
'ended_at' => '2024-01-18T20:00:00Z',
|
||||||
|
'distance' => 1245.67,
|
||||||
|
'created_at' => '2024-01-19T00:00:00Z',
|
||||||
|
'updated_at' => '2024-01-19T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name' => 'Weekend Getaway',
|
||||||
|
'started_at' => '2024-02-10T09:00:00Z',
|
||||||
|
'ended_at' => '2024-02-12T18:00:00Z',
|
||||||
|
'distance' => 456.78,
|
||||||
|
'created_at' => '2024-02-13T00:00:00Z',
|
||||||
|
'updated_at' => '2024-02-13T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
let(:service) { described_class.new(user, trips_data) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Mock the job enqueuing to avoid it interfering with tests
|
||||||
|
allow(Trips::CalculateAllJob).to receive(:perform_later)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#call' do
|
||||||
|
context 'with valid trips data' do
|
||||||
|
it 'creates new trips for the user' do
|
||||||
|
expect { service.call }.to change { user.trips.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates trips with correct attributes' do
|
||||||
|
service.call
|
||||||
|
|
||||||
|
business_trip = user.trips.find_by(name: 'Business Trip to NYC')
|
||||||
|
expect(business_trip).to have_attributes(
|
||||||
|
name: 'Business Trip to NYC',
|
||||||
|
started_at: Time.parse('2024-01-15T08:00:00Z'),
|
||||||
|
ended_at: Time.parse('2024-01-18T20:00:00Z'),
|
||||||
|
distance: 1245
|
||||||
|
)
|
||||||
|
|
||||||
|
weekend_trip = user.trips.find_by(name: 'Weekend Getaway')
|
||||||
|
expect(weekend_trip).to have_attributes(
|
||||||
|
name: 'Weekend Getaway',
|
||||||
|
started_at: Time.parse('2024-02-10T09:00:00Z'),
|
||||||
|
ended_at: Time.parse('2024-02-12T18:00:00Z'),
|
||||||
|
distance: 456
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the number of trips created' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 2 trips for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Trips import completed. Created: 2")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with duplicate trips' do
|
||||||
|
before do
|
||||||
|
# Create an existing trip with same name and times
|
||||||
|
user.trips.create!(
|
||||||
|
name: 'Business Trip to NYC',
|
||||||
|
started_at: Time.parse('2024-01-15T08:00:00Z'),
|
||||||
|
ended_at: Time.parse('2024-01-18T20:00:00Z'),
|
||||||
|
distance: 1000.0
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips duplicate trips' do
|
||||||
|
expect { service.call }.to change { user.trips.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs when skipping duplicates' do
|
||||||
|
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||||
|
expect(Rails.logger).to receive(:debug).with("Trip already exists: Business Trip to NYC")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns only the count of newly created trips' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid trip data' do
|
||||||
|
let(:trips_data) do
|
||||||
|
[
|
||||||
|
{ 'name' => 'Valid Trip', 'started_at' => '2024-01-15T08:00:00Z', 'ended_at' => '2024-01-18T20:00:00Z' },
|
||||||
|
'invalid_data',
|
||||||
|
{ 'name' => 'Another Valid Trip', 'started_at' => '2024-02-10T09:00:00Z', 'ended_at' => '2024-02-12T18:00:00Z' }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips invalid entries and imports valid ones' do
|
||||||
|
expect { service.call }.to change { user.trips.count }.by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the count of valid trips created' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with validation errors' do
|
||||||
|
let(:trips_data) do
|
||||||
|
[
|
||||||
|
{ 'name' => 'Valid Trip', 'started_at' => '2024-01-15T08:00:00Z', 'ended_at' => '2024-01-18T20:00:00Z' },
|
||||||
|
{ 'started_at' => '2024-01-15T08:00:00Z', 'ended_at' => '2024-01-18T20:00:00Z' }, # missing name
|
||||||
|
{ 'name' => 'Invalid Trip' } # missing required timestamps
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'only creates valid trips' do
|
||||||
|
expect { service.call }.to change { user.trips.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs validation errors' do
|
||||||
|
expect(Rails.logger).to receive(:error).at_least(:once)
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with nil trips data' do
|
||||||
|
let(:trips_data) { nil }
|
||||||
|
|
||||||
|
it 'does not create any trips' do
|
||||||
|
expect { service.call }.not_to change { user.trips.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with non-array trips data' do
|
||||||
|
let(:trips_data) { 'invalid_data' }
|
||||||
|
|
||||||
|
it 'does not create any trips' do
|
||||||
|
expect { service.call }.not_to change { user.trips.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with empty trips data' do
|
||||||
|
let(:trips_data) { [] }
|
||||||
|
|
||||||
|
it 'does not create any trips' do
|
||||||
|
expect { service.call }.not_to change { user.trips.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the import process with 0 count' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Importing 0 trips for user: #{user.email}")
|
||||||
|
expect(Rails.logger).to receive(:info).with("Trips import completed. Created: 0")
|
||||||
|
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0' do
|
||||||
|
result = service.call
|
||||||
|
expect(result).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
297
spec/services/users/import_data_spec.rb
Normal file
297
spec/services/users/import_data_spec.rb
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::ImportData, type: :service do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:archive_path) { Rails.root.join('tmp', 'test_export.zip') }
|
||||||
|
let(:service) { described_class.new(user, archive_path) }
|
||||||
|
let(:import_directory) { Rails.root.join('tmp', "import_#{user.email.gsub(/[^0-9A-Za-z._-]/, '_')}_1234567890") }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Time).to receive(:current).and_return(Time.at(1234567890))
|
||||||
|
allow(FileUtils).to receive(:mkdir_p)
|
||||||
|
allow(FileUtils).to receive(:rm_rf)
|
||||||
|
allow(File).to receive(:directory?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#import' do
|
||||||
|
let(:sample_data) do
|
||||||
|
{
|
||||||
|
'counts' => {
|
||||||
|
'areas' => 2,
|
||||||
|
'places' => 3,
|
||||||
|
'imports' => 1,
|
||||||
|
'exports' => 1,
|
||||||
|
'trips' => 2,
|
||||||
|
'stats' => 1,
|
||||||
|
'notifications' => 2,
|
||||||
|
'visits' => 4,
|
||||||
|
'points' => 1000
|
||||||
|
},
|
||||||
|
'settings' => { 'theme' => 'dark' },
|
||||||
|
'areas' => [{ 'name' => 'Home', 'latitude' => '40.7128', 'longitude' => '-74.0060' }],
|
||||||
|
'places' => [{ 'name' => 'Office', 'latitude' => '40.7589', 'longitude' => '-73.9851' }],
|
||||||
|
'imports' => [{ 'name' => 'test.json', 'source' => 'owntracks' }],
|
||||||
|
'exports' => [{ 'name' => 'export.json', 'status' => 'completed' }],
|
||||||
|
'trips' => [{ 'name' => 'Trip to NYC', 'distance' => 100.5 }],
|
||||||
|
'stats' => [{ 'year' => 2024, 'month' => 1, 'distance' => 456.78 }],
|
||||||
|
'notifications' => [{ 'title' => 'Test', 'content' => 'Test notification' }],
|
||||||
|
'visits' => [{ 'name' => 'Work Visit', 'duration' => 3600 }],
|
||||||
|
'points' => [{ 'latitude' => 40.7128, 'longitude' => -74.0060, 'timestamp' => 1234567890 }]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Mock ZIP file extraction
|
||||||
|
zipfile_mock = double('ZipFile')
|
||||||
|
allow(zipfile_mock).to receive(:each)
|
||||||
|
allow(Zip::File).to receive(:open).with(archive_path).and_yield(zipfile_mock)
|
||||||
|
|
||||||
|
# Mock JSON loading and File operations
|
||||||
|
allow(File).to receive(:exist?).and_return(false)
|
||||||
|
allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true)
|
||||||
|
allow(File).to receive(:read).with(import_directory.join('data.json')).and_return(sample_data.to_json)
|
||||||
|
|
||||||
|
# Mock all import services
|
||||||
|
allow(Users::ImportData::Settings).to receive(:new).and_return(double(call: true))
|
||||||
|
allow(Users::ImportData::Areas).to receive(:new).and_return(double(call: 2))
|
||||||
|
allow(Users::ImportData::Places).to receive(:new).and_return(double(call: 3))
|
||||||
|
allow(Users::ImportData::Imports).to receive(:new).and_return(double(call: [1, 5]))
|
||||||
|
allow(Users::ImportData::Exports).to receive(:new).and_return(double(call: [1, 2]))
|
||||||
|
allow(Users::ImportData::Trips).to receive(:new).and_return(double(call: 2))
|
||||||
|
allow(Users::ImportData::Stats).to receive(:new).and_return(double(call: 1))
|
||||||
|
allow(Users::ImportData::Notifications).to receive(:new).and_return(double(call: 2))
|
||||||
|
allow(Users::ImportData::Visits).to receive(:new).and_return(double(call: 4))
|
||||||
|
allow(Users::ImportData::Points).to receive(:new).and_return(double(call: 1000))
|
||||||
|
|
||||||
|
# Mock notifications
|
||||||
|
allow(::Notifications::Create).to receive(:new).and_return(double(call: true))
|
||||||
|
|
||||||
|
# Mock cleanup
|
||||||
|
allow(service).to receive(:cleanup_temporary_files)
|
||||||
|
allow_any_instance_of(Pathname).to receive(:exist?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when import is successful' do
|
||||||
|
it 'creates import directory' do
|
||||||
|
expect(FileUtils).to receive(:mkdir_p).with(import_directory)
|
||||||
|
|
||||||
|
service.import
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'extracts the archive' do
|
||||||
|
expect(Zip::File).to receive(:open).with(archive_path)
|
||||||
|
|
||||||
|
service.import
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'loads JSON data from extracted files' do
|
||||||
|
expect(File).to receive(:exist?).with(import_directory.join('data.json'))
|
||||||
|
expect(File).to receive(:read).with(import_directory.join('data.json'))
|
||||||
|
|
||||||
|
service.import
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calls all import services in correct order' do
|
||||||
|
expect(Users::ImportData::Settings).to receive(:new).with(user, sample_data['settings']).ordered
|
||||||
|
expect(Users::ImportData::Areas).to receive(:new).with(user, sample_data['areas']).ordered
|
||||||
|
expect(Users::ImportData::Places).to receive(:new).with(user, sample_data['places']).ordered
|
||||||
|
expect(Users::ImportData::Imports).to receive(:new).with(user, sample_data['imports'], import_directory.join('files')).ordered
|
||||||
|
expect(Users::ImportData::Exports).to receive(:new).with(user, sample_data['exports'], import_directory.join('files')).ordered
|
||||||
|
expect(Users::ImportData::Trips).to receive(:new).with(user, sample_data['trips']).ordered
|
||||||
|
expect(Users::ImportData::Stats).to receive(:new).with(user, sample_data['stats']).ordered
|
||||||
|
expect(Users::ImportData::Notifications).to receive(:new).with(user, sample_data['notifications']).ordered
|
||||||
|
expect(Users::ImportData::Visits).to receive(:new).with(user, sample_data['visits']).ordered
|
||||||
|
expect(Users::ImportData::Points).to receive(:new).with(user, sample_data['points']).ordered
|
||||||
|
|
||||||
|
service.import
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates success notification with import stats' do
|
||||||
|
expect(::Notifications::Create).to receive(:new).with(
|
||||||
|
user: user,
|
||||||
|
title: 'Data import completed',
|
||||||
|
content: match(/1000 points.*4 visits.*3 places.*2 trips/),
|
||||||
|
kind: :info
|
||||||
|
)
|
||||||
|
|
||||||
|
service.import
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'cleans up temporary files' do
|
||||||
|
expect(service).to receive(:cleanup_temporary_files).with(import_directory)
|
||||||
|
|
||||||
|
service.import
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns import statistics' do
|
||||||
|
result = service.import
|
||||||
|
|
||||||
|
expect(result).to include(
|
||||||
|
settings_updated: true,
|
||||||
|
areas_created: 2,
|
||||||
|
places_created: 3,
|
||||||
|
imports_created: 1,
|
||||||
|
exports_created: 1,
|
||||||
|
trips_created: 2,
|
||||||
|
stats_created: 1,
|
||||||
|
notifications_created: 2,
|
||||||
|
visits_created: 4,
|
||||||
|
points_created: 1000,
|
||||||
|
files_restored: 7
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs expected counts if available' do
|
||||||
|
allow(Rails.logger).to receive(:info) # Allow other log messages
|
||||||
|
expect(Rails.logger).to receive(:info).with(/Expected entity counts from export:/)
|
||||||
|
|
||||||
|
service.import
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when JSON file is missing' do
|
||||||
|
before do
|
||||||
|
allow(File).to receive(:exist?).and_return(false)
|
||||||
|
allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(false)
|
||||||
|
allow(ExceptionReporter).to receive(:call)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises an error' do
|
||||||
|
expect { service.import }.to raise_error(StandardError, 'Data file not found in archive: data.json')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when JSON is invalid' do
|
||||||
|
before do
|
||||||
|
allow(File).to receive(:exist?).and_return(false)
|
||||||
|
allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true)
|
||||||
|
allow(File).to receive(:read).with(import_directory.join('data.json')).and_return('invalid json')
|
||||||
|
allow(ExceptionReporter).to receive(:call)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises a JSON parse error' do
|
||||||
|
expect { service.import }.to raise_error(StandardError, /Invalid JSON format in data file/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when an error occurs during import' do
|
||||||
|
let(:error_message) { 'Something went wrong' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(File).to receive(:exist?).and_return(false)
|
||||||
|
allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true)
|
||||||
|
allow(File).to receive(:read).with(import_directory.join('data.json')).and_return(sample_data.to_json)
|
||||||
|
allow(Users::ImportData::Settings).to receive(:new).and_raise(StandardError, error_message)
|
||||||
|
allow(ExceptionReporter).to receive(:call)
|
||||||
|
allow(::Notifications::Create).to receive(:new).and_return(double(call: true))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates failure notification' do
|
||||||
|
expect(::Notifications::Create).to receive(:new).with(
|
||||||
|
user: user,
|
||||||
|
title: 'Data import failed',
|
||||||
|
content: "Your data import failed with error: #{error_message}. Please check the archive format and try again.",
|
||||||
|
kind: :error
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { service.import }.to raise_error(StandardError, error_message)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reports error via ExceptionReporter' do
|
||||||
|
expect(ExceptionReporter).to receive(:call).with(
|
||||||
|
an_instance_of(StandardError),
|
||||||
|
'Data import failed'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { service.import }.to raise_error(StandardError, error_message)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'still cleans up temporary files' do
|
||||||
|
expect(service).to receive(:cleanup_temporary_files)
|
||||||
|
|
||||||
|
expect { service.import }.to raise_error(StandardError, error_message)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 're-raises the error' do
|
||||||
|
expect { service.import }.to raise_error(StandardError, error_message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when data sections are missing' do
|
||||||
|
let(:minimal_data) { { 'settings' => { 'theme' => 'dark' } } }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Reset JSON file mocking
|
||||||
|
allow(File).to receive(:exist?).and_return(false)
|
||||||
|
allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true)
|
||||||
|
allow(File).to receive(:read).with(import_directory.join('data.json')).and_return(minimal_data.to_json)
|
||||||
|
|
||||||
|
# Only expect Settings to be called
|
||||||
|
allow(Users::ImportData::Settings).to receive(:new).and_return(double(call: true))
|
||||||
|
allow(::Notifications::Create).to receive(:new).and_return(double(call: true))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'only imports available sections' do
|
||||||
|
expect(Users::ImportData::Settings).to receive(:new).with(user, minimal_data['settings'])
|
||||||
|
expect(Users::ImportData::Areas).not_to receive(:new)
|
||||||
|
expect(Users::ImportData::Places).not_to receive(:new)
|
||||||
|
|
||||||
|
service.import
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'private methods' do
|
||||||
|
describe '#cleanup_temporary_files' do
|
||||||
|
context 'when directory exists' do
|
||||||
|
before do
|
||||||
|
allow(File).to receive(:directory?).and_return(true)
|
||||||
|
allow(Rails.logger).to receive(:info)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'removes the directory' do
|
||||||
|
expect(FileUtils).to receive(:rm_rf).with(import_directory)
|
||||||
|
|
||||||
|
service.send(:cleanup_temporary_files, import_directory)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the cleanup' do
|
||||||
|
expect(Rails.logger).to receive(:info).with("Cleaning up temporary import directory: #{import_directory}")
|
||||||
|
|
||||||
|
service.send(:cleanup_temporary_files, import_directory)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when cleanup fails' do
|
||||||
|
before do
|
||||||
|
allow(File).to receive(:directory?).and_return(true)
|
||||||
|
allow(FileUtils).to receive(:rm_rf).and_raise(StandardError, 'Permission denied')
|
||||||
|
allow(ExceptionReporter).to receive(:call)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reports error via ExceptionReporter but does not re-raise' do
|
||||||
|
expect(ExceptionReporter).to receive(:call).with(
|
||||||
|
an_instance_of(StandardError),
|
||||||
|
'Failed to cleanup temporary files'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { service.send(:cleanup_temporary_files, import_directory) }.not_to raise_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when directory does not exist' do
|
||||||
|
before do
|
||||||
|
allow(File).to receive(:directory?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not attempt cleanup' do
|
||||||
|
expect(FileUtils).not_to receive(:rm_rf)
|
||||||
|
|
||||||
|
service.send(:cleanup_temporary_files, import_directory)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in a new issue