add location logging, display last location on map

Signed-off-by: Nicolas Froger <nicolas@kektus.xyz>
This commit is contained in:
Nicolas Froger 2024-07-26 15:36:19 +02:00
commit 130581a411
No known key found for this signature in database
13 changed files with 386 additions and 3 deletions

View file

@ -0,0 +1,22 @@
package fr.kektus.summer2024.converters;
import fr.kektus.summer2024.data.model.Location;
import fr.kektus.summer2024.domain.entity.LocationEntity;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class LocationConverter implements Converter<Location, LocationEntity> {
@Override public Location toLeft(LocationEntity locationEntity) {
return new Location().withId(locationEntity.id)
.withLatitude(locationEntity.latitude)
.withLongitude(locationEntity.longitude)
.withTimestamp(locationEntity.timestamp);
}
@Override public LocationEntity toRight(Location location) {
return new LocationEntity().withId(location.id)
.withLatitude(location.latitude)
.withLongitude(location.longitude)
.withTimestamp(location.timestamp);
}
}

View file

@ -0,0 +1,20 @@
package fr.kektus.summer2024.data.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
import java.time.ZonedDateTime;
@Entity
@AllArgsConstructor @NoArgsConstructor @With
public class Location {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) public Long id;
public ZonedDateTime timestamp;
public Float latitude;
public Float longitude;
}

View file

@ -0,0 +1,9 @@
package fr.kektus.summer2024.data.repository;
import fr.kektus.summer2024.data.model.Location;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class LocationRepository implements PanacheRepository<Location> {
}

View file

@ -0,0 +1,15 @@
package fr.kektus.summer2024.domain.entity;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
import java.time.ZonedDateTime;
@AllArgsConstructor @NoArgsConstructor @With
public class LocationEntity {
public Long id;
public ZonedDateTime timestamp;
public Float latitude;
public Float longitude;
}

View file

@ -0,0 +1,35 @@
package fr.kektus.summer2024.domain.service;
import fr.kektus.summer2024.converters.LocationConverter;
import fr.kektus.summer2024.data.model.Location;
import fr.kektus.summer2024.data.repository.LocationRepository;
import fr.kektus.summer2024.domain.entity.LocationEntity;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import java.time.ZonedDateTime;
@ApplicationScoped
public class LocationService {
@Inject LocationRepository locationRepository;
@Inject LocationConverter locationConverter;
@Transactional
public LocationEntity createLocation(Float lat, Float lon) {
final Location location = new Location().withLatitude(lat)
.withLongitude(lon)
.withTimestamp(ZonedDateTime.now());
locationRepository.persist(location);
return locationConverter.toRight(location);
}
public LocationEntity getLatestLocation() {
final Location lastLocation = locationRepository.findAll(Sort.descending("id")).firstResult();
if (lastLocation == null)
throw new NotFoundException();
return locationConverter.toRight(lastLocation);
}
}

View file

@ -0,0 +1,47 @@
package fr.kektus.summer2024.presentation.rest;
import fr.kektus.summer2024.Validators;
import fr.kektus.summer2024.domain.entity.LocationEntity;
import fr.kektus.summer2024.domain.service.AuthService;
import fr.kektus.summer2024.domain.service.LocationService;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
import org.jboss.resteasy.reactive.RestHeader;
@Path("/admin/location")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class AdminLocationApi {
@Inject AuthService authService;
@Inject LocationService locationService;
@POST
public LocationEntity createPost(@RestHeader("X-admin-token") final String adminToken,
CreateLocation.Request request) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
Validators.assertNotNull(request, new BadRequestException());
Validators.assertNotNull(request.latitude, new BadRequestException());
Validators.assertNotNull(request.longitude, new BadRequestException());
return locationService.createLocation(request.latitude, request.longitude);
}
public static class CreateLocation {
@NoArgsConstructor @AllArgsConstructor @With
public static class Request {
public Float latitude;
public Float longitude;
}
}
}

View file

@ -0,0 +1,21 @@
package fr.kektus.summer2024.presentation.rest;
import fr.kektus.summer2024.domain.entity.LocationEntity;
import fr.kektus.summer2024.domain.service.LocationService;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/location")
@Produces(MediaType.APPLICATION_JSON)
public class LocationApi {
@Inject LocationService locationService;
@GET @Path("/last")
public LocationEntity getLastLocation() {
return locationService.getLatestLocation();
}
}

View file

@ -0,0 +1,7 @@
create table Location
(
id bigserial not null primary key,
latitude float4,
longitude float4,
timestamp timestamp(6) with time zone
);

View file

@ -6,6 +6,7 @@ const CreatePostView = () => import('@/views/CreatePostView.vue')
const AdminView = () => import('@/views/AdminView.vue') const AdminView = () => import('@/views/AdminView.vue')
const LoginView = () => import('@/views/LoginView.vue') const LoginView = () => import('@/views/LoginView.vue')
const MapView = () => import('@/views/MapView.vue') const MapView = () => import('@/views/MapView.vue')
const SendLocationView = () => import('@/views/SendLocationView.vue')
function authenticatedRoute(to) { function authenticatedRoute(to) {
const authStore = useAuthStore() const authStore = useAuthStore()
@ -46,6 +47,12 @@ const router = createRouter({
name: 'admin_create_post', name: 'admin_create_post',
component: CreatePostView, component: CreatePostView,
beforeEnter: [authenticatedRoute] beforeEnter: [authenticatedRoute]
},
{
path: '/admin/send-location',
name: 'admin_send_location',
component: SendLocationView,
beforeEnter: [authenticatedRoute]
} }
] ]
}) })

View file

@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { formatRelative } from 'date-fns'
import { fr } from 'date-fns/locale'
import { API_BASE_URL } from '@/config.js'
import { fromLonLat } from 'ol/proj.js'
export const useLocationStore = defineStore('location', () => {
const locationApiPath = API_BASE_URL + '/location/last'
const lastLocation = ref(null)
function fetchLocation() {
return fetch(locationApiPath)
.then((response) => {
return response.json()
})
.then(async (loc) => {
const locDate = new Date(loc.timestamp)
loc.formatedDate = formatRelative(locDate, new Date(), { locale: fr })
loc.projectedCoordinates = fromLonLat([loc.longitude, loc.latitude])
lastLocation.value = loc
return location.value
})
.catch((error) => {
console.log('fetch last location failed with: ' + error)
})
}
return { lastLocation, fetchLocation }
})

View file

@ -1,7 +1,7 @@
<script setup> <script setup>
import { Button } from '@/components/ui/button/index.js' import { Button } from '@/components/ui/button/index.js'
import { CirclePlus, Pencil, Trash } from 'lucide-vue-next' import { CirclePlus, Pencil, Trash, MapPin } from 'lucide-vue-next'
import { useAdminPostsStore } from '@/stores/adminPosts.js' import { useAdminPostsStore } from '@/stores/adminPosts.js'
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { import {
@ -27,6 +27,12 @@ onMounted(() => {
<h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-6"> <h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-6">
Admin Admin
</h1> </h1>
<RouterLink to="/admin/send-location">
<Button class="mb-6 mr-2">
<MapPin class="mr-2" />
Envoyer localisation
</Button>
</RouterLink>
<RouterLink to="/admin/post/create"> <RouterLink to="/admin/post/create">
<Button class="mb-6"> <Button class="mb-6">
<CirclePlus class="mr-2" /> <CirclePlus class="mr-2" />

View file

@ -2,13 +2,18 @@
import { usePostsStore } from '@/stores/posts.js' import { usePostsStore } from '@/stores/posts.js'
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { Camera } from 'lucide-vue-next' import { Camera, MapPin } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip/index.js' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip/index.js'
import { useLocationStore } from '@/stores/location.js'
const postStore = usePostsStore() const postStore = usePostsStore()
const locationStore = useLocationStore()
const postsLinesCoordinates = computed(() => { const postsLinesCoordinates = computed(() => {
const coords = [] const coords = []
if (locationStore.lastLocation != null) {
coords.push(locationStore.lastLocation.projectedCoordinates)
}
for (const post of postStore.posts) { for (const post of postStore.posts) {
coords.push(post.projectedCoordinates) coords.push(post.projectedCoordinates)
} }
@ -17,6 +22,7 @@ const postsLinesCoordinates = computed(() => {
onMounted(() => { onMounted(() => {
postStore.fetchPosts() postStore.fetchPosts()
locationStore.fetchLocation()
}) })
</script> </script>
@ -59,8 +65,27 @@ onMounted(() => {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</ol-overlay> </ol-overlay>
<ol-overlay
v-if="locationStore.lastLocation != null"
:position="locationStore.lastLocation.projectedCoordinates"
:autoPan="true"
>
<TooltipProvider>
<Tooltip :default-open="true">
<TooltipTrigger as-child>
<div
class="-translate-x-1/2 -translate-y-1/2 p-1 rounded-lg bg-rose-950 hover:bg-rose-900 transition-colors duration-200">
<MapPin size="16" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Dernière position, {{ locationStore.lastLocation.formatedDate }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</ol-overlay>
<ol-vector-layer v-if="postStore.posts.length > 1"> <ol-vector-layer v-if="postsLinesCoordinates.length > 1">
<ol-source-vector> <ol-source-vector>
<ol-feature ref="profileFeatureRef"> <ol-feature ref="profileFeatureRef">
<ol-geom-line-string <ol-geom-line-string

View file

@ -0,0 +1,137 @@
<script setup>
import { Button } from '@/components/ui/button/index.js'
import { useForm } from 'vee-validate'
import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form/index.js'
import { Input } from '@/components/ui/input/index.js'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { onMounted, onUnmounted, ref } from 'vue'
import { AlertCircle, ArrowLeft, CircleCheckBig, Send } from 'lucide-vue-next'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert/index.js'
import { useAuthStore } from '@/stores/auth.js'
import { API_BASE_URL } from '@/config.js'
const authStore = useAuthStore()
const formSchema = toTypedSchema(z.object({
latitude: z.number(),
longitude: z.number()
}))
const form = useForm({
validationSchema: formSchema
})
const formContainer = ref(null)
const latitudeInput = ref(null)
const longitudeInput = ref(null)
const formStatus = ref({
sending: false,
sent: false,
error: false,
errorMsg: ''
})
const onSubmit = form.handleSubmit(async (values) => {
if (!authStore.isAuth)
return
console.log('Envoi de la localisation...')
formContainer.value.classList.add('hidden')
formStatus.value.sending = true
const response = await fetch(API_BASE_URL + '/admin/location', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-admin-token': authStore.adminToken
},
body: JSON.stringify({
latitude: values.latitude,
longitude: values.longitude
})
})
if (!response.ok) {
console.log('POST post API failed: ' + response.statusText + '\n\n' + response.body)
formStatus.value.sending = false
formStatus.value.error = true
formStatus.value.errorMsg = 'Une erreur est survenue lors de l\'envoi du poste : ' + response.statusText
formContainer.value.classList.remove('hidden')
return
}
formStatus.value.sending = false
formStatus.value.sent = true
})
let geoWatchId = null
onMounted(() => {
geoWatchId = navigator.geolocation.watchPosition((position) => {
console.log(position)
form.setFieldValue('latitude', position.coords.latitude)
form.setFieldValue('longitude', position.coords.longitude)
})
})
onUnmounted(() => {
if (geoWatchId != null) {
navigator.geolocation.clearWatch(geoWatchId)
}
})
</script>
<template>
<div class="grid grid-cols-3 grid-rows-1 mt-28 sm:mt-20 w-full justify-center items-center gap-4">
<div class="col-span-3 col-start-1 lg:col-span-1 lg:col-start-2 mx-5 lg:mx-0">
<h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-6">
Envoyer la localisation
</h1>
<RouterLink to="/admin">
<Button class="mb-6">
<ArrowLeft class="mr-2" />
Retour
</Button>
</RouterLink>
<Alert variant="destructive" class="mb-6" v-if="formStatus.error">
<AlertCircle class="w-4 h-4"></AlertCircle>
<AlertTitle>Erreur...</AlertTitle>
<AlertDescription>{{ formStatus.errorMsg }}</AlertDescription>
</Alert>
<Alert class="mb-6" v-if="formStatus.sent">
<CircleCheckBig class="w-4 h-4"></CircleCheckBig>
<AlertTitle>Localisation envoyée !</AlertTitle>
</Alert>
<p v-if="formStatus.sending">Sending...</p>
<div id="form-container" ref="formContainer">
<form id="post-form" class="space-y-6" @submit="onSubmit">
<div class="grid grid-cols-2 gap-4">
<FormField ref="latitudeInput" v-slot="{ componentField }" name="latitude">
<FormItem>
<FormLabel>Latitude</FormLabel>
<FormControl>
<Input disabled v-bind="componentField"></Input>
</FormControl>
</FormItem>
</FormField>
<FormField ref="longitudeInput" v-slot="{ componentField }" name="longitude">
<FormItem>
<FormLabel>Longitude</FormLabel>
<FormControl>
<Input disabled v-bind="componentField"></Input>
</FormControl>
</FormItem>
</FormField>
</div>
<Button class="mt-6 w-full" type="submit">
<Send />
</Button>
</form>
</div>
</div>
</div>
</template>
<style scoped>
</style>