diff --git a/summer2024-backend/src/main/java/fr/kektus/summer2024/converters/LocationConverter.java b/summer2024-backend/src/main/java/fr/kektus/summer2024/converters/LocationConverter.java new file mode 100644 index 0000000..fddd483 --- /dev/null +++ b/summer2024-backend/src/main/java/fr/kektus/summer2024/converters/LocationConverter.java @@ -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 { + @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); + } +} diff --git a/summer2024-backend/src/main/java/fr/kektus/summer2024/data/model/Location.java b/summer2024-backend/src/main/java/fr/kektus/summer2024/data/model/Location.java new file mode 100644 index 0000000..bd1e1d6 --- /dev/null +++ b/summer2024-backend/src/main/java/fr/kektus/summer2024/data/model/Location.java @@ -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; +} diff --git a/summer2024-backend/src/main/java/fr/kektus/summer2024/data/repository/LocationRepository.java b/summer2024-backend/src/main/java/fr/kektus/summer2024/data/repository/LocationRepository.java new file mode 100644 index 0000000..fe8a2c6 --- /dev/null +++ b/summer2024-backend/src/main/java/fr/kektus/summer2024/data/repository/LocationRepository.java @@ -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 { +} diff --git a/summer2024-backend/src/main/java/fr/kektus/summer2024/domain/entity/LocationEntity.java b/summer2024-backend/src/main/java/fr/kektus/summer2024/domain/entity/LocationEntity.java new file mode 100644 index 0000000..939f86b --- /dev/null +++ b/summer2024-backend/src/main/java/fr/kektus/summer2024/domain/entity/LocationEntity.java @@ -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; +} diff --git a/summer2024-backend/src/main/java/fr/kektus/summer2024/domain/service/LocationService.java b/summer2024-backend/src/main/java/fr/kektus/summer2024/domain/service/LocationService.java new file mode 100644 index 0000000..58d9561 --- /dev/null +++ b/summer2024-backend/src/main/java/fr/kektus/summer2024/domain/service/LocationService.java @@ -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); + } +} diff --git a/summer2024-backend/src/main/java/fr/kektus/summer2024/presentation/rest/AdminLocationApi.java b/summer2024-backend/src/main/java/fr/kektus/summer2024/presentation/rest/AdminLocationApi.java new file mode 100644 index 0000000..908163c --- /dev/null +++ b/summer2024-backend/src/main/java/fr/kektus/summer2024/presentation/rest/AdminLocationApi.java @@ -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; + } + } +} diff --git a/summer2024-backend/src/main/java/fr/kektus/summer2024/presentation/rest/LocationApi.java b/summer2024-backend/src/main/java/fr/kektus/summer2024/presentation/rest/LocationApi.java new file mode 100644 index 0000000..c1c64b0 --- /dev/null +++ b/summer2024-backend/src/main/java/fr/kektus/summer2024/presentation/rest/LocationApi.java @@ -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(); + } +} diff --git a/summer2024-backend/src/main/resources/db/migration/V1.2.0__AddLocation.sql b/summer2024-backend/src/main/resources/db/migration/V1.2.0__AddLocation.sql new file mode 100644 index 0000000..13ef5ad --- /dev/null +++ b/summer2024-backend/src/main/resources/db/migration/V1.2.0__AddLocation.sql @@ -0,0 +1,7 @@ +create table Location +( + id bigserial not null primary key, + latitude float4, + longitude float4, + timestamp timestamp(6) with time zone +); \ No newline at end of file diff --git a/summer2024-frontend/src/router/index.js b/summer2024-frontend/src/router/index.js index b8fc864..9476757 100644 --- a/summer2024-frontend/src/router/index.js +++ b/summer2024-frontend/src/router/index.js @@ -6,6 +6,7 @@ const CreatePostView = () => import('@/views/CreatePostView.vue') const AdminView = () => import('@/views/AdminView.vue') const LoginView = () => import('@/views/LoginView.vue') const MapView = () => import('@/views/MapView.vue') +const SendLocationView = () => import('@/views/SendLocationView.vue') function authenticatedRoute(to) { const authStore = useAuthStore() @@ -46,6 +47,12 @@ const router = createRouter({ name: 'admin_create_post', component: CreatePostView, beforeEnter: [authenticatedRoute] + }, + { + path: '/admin/send-location', + name: 'admin_send_location', + component: SendLocationView, + beforeEnter: [authenticatedRoute] } ] }) diff --git a/summer2024-frontend/src/stores/location.js b/summer2024-frontend/src/stores/location.js new file mode 100644 index 0000000..354714b --- /dev/null +++ b/summer2024-frontend/src/stores/location.js @@ -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 } +}) diff --git a/summer2024-frontend/src/views/AdminView.vue b/summer2024-frontend/src/views/AdminView.vue index 47b0c8f..0e28de9 100644 --- a/summer2024-frontend/src/views/AdminView.vue +++ b/summer2024-frontend/src/views/AdminView.vue @@ -1,7 +1,7 @@ @@ -59,8 +65,27 @@ onMounted(() => { + + + + +
+ +
+
+ +

Dernière position, {{ locationStore.lastLocation.formatedDate }}

+
+
+
+
- + +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) + } +}) + + + + + +