all repos — caroster @ 25ef569b9aff78df82eea6d516cca3a4aeccbb8a

[Octree] Group carpool to your event https://caroster.io

feat: :sparkles: Create trip alerts management system
Tim Izzo tim@octree.ch
Fri, 02 Feb 2024 13:10:36 +0000
commit

25ef569b9aff78df82eea6d516cca3a4aeccbb8a

parent

3a6483727355d59a734458de22d13c6088c9ce29

M backend/src/api/travel/content-types/travel/lifecycles.tsbackend/src/api/travel/content-types/travel/lifecycles.ts

@@ -6,7 +6,22 @@

export default { async afterCreate({ result, params }) { const eventId = params?.data?.event; - if (eventId) sendEmailsToWaitingPassengers(result, eventId); + + const event = await strapi.entityService.findOne( + "api::event.event", + eventId + ); + if (!event) + throw new Error("Try to create a travel not linked to an existing event"); + + const enabledModules = event.enabled_modules as String[]; + const isEventCarosterPlus = enabledModules?.includes("caroster-plus"); + + if (isEventCarosterPlus) + strapi + .service("api::trip-alert.trip-alert") + .sendTripAlerts(eventId, result); + else sendEmailsToWaitingPassengers(result, eventId); }, async beforeUpdate(event) {
A backend/src/api/trip-alert/content-types/trip-alert/schema.json

@@ -0,0 +1,43 @@

+{ + "kind": "collectionType", + "collectionName": "trip_alerts", + "info": { + "singularName": "trip-alert", + "pluralName": "trip-alerts", + "displayName": "TripAlert", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "event": { + "type": "relation", + "relation": "oneToOne", + "target": "api::event.event" + }, + "user": { + "type": "relation", + "relation": "oneToOne", + "target": "plugin::users-permissions.user" + }, + "latitude": { + "type": "float" + }, + "longitude": { + "type": "float" + }, + "enabled": { + "type": "boolean", + "default": true + }, + "address": { + "type": "string" + }, + "radius": { + "type": "float", + "min": 0 + } + } +}
A backend/src/api/trip-alert/controllers/trip-alert.ts

@@ -0,0 +1,7 @@

+/** + * trip-alert controller + */ + +import { factories } from '@strapi/strapi' + +export default factories.createCoreController('api::trip-alert.trip-alert');
A backend/src/api/trip-alert/routes/trip-alert.ts

@@ -0,0 +1,7 @@

+/** + * trip-alert router + */ + +import { factories } from '@strapi/strapi'; + +export default factories.createCoreRouter('api::trip-alert.trip-alert');
A backend/src/api/trip-alert/services/trip-alert.ts

@@ -0,0 +1,64 @@

+import { factories } from "@strapi/strapi"; +import pMap from "p-map"; +import { + Coordinates, + calculateHaversineDistance, +} from "../../../utils/geography"; + +export default factories.createCoreService( + "api::trip-alert.trip-alert", + ({ strapi }) => ({ + async sendTripAlerts(eventId: string, travel) { + if (!travel.meeting_latitude || !travel.meeting_longitude) { + strapi.log.warn( + `Can't send trip alert for travel ${travel.id}. No coordinates found.` + ); + return; + } + + const travelCoordinates: Coordinates = [ + travel.meeting_latitude, + travel.meeting_longitude, + ]; + const eventTripAlerts = await strapi.entityService.findMany( + "api::trip-alert.trip-alert", + { + filters: { + enabled: true, + event: { id: eventId }, + }, + populate: ["user"], + } + ); + const filteredTripAlerts = eventTripAlerts.filter((tripAlert) => { + // If alert has no geographical info, send alert on each new trip + if (!tripAlert.latitude || !tripAlert.longitude || !tripAlert.radius) + return true; + + // Else, check if new travel is in alert area + const alertCoordinates: Coordinates = [ + tripAlert.latitude, + tripAlert.longitude, + ]; + const distance = calculateHaversineDistance( + travelCoordinates, + alertCoordinates + ); + return distance <= tripAlert.radius; + }); + + await pMap(filteredTripAlerts, async (tripAlert) => { + strapi.log.debug( + `Create trip alert notification for user ${tripAlert.user.id}` + ); + strapi.entityService.create("api::notification.notification", { + data: { + type: "NewTrip", + event: eventId, + user: tripAlert.user.id, + }, + }); + }); + }, + }) +);
M backend/src/graphql/index.tsbackend/src/graphql/index.ts

@@ -5,6 +5,7 @@ import travelExtensions from "./travel";

import vehicleExtensions from "./vehicle"; import passengerExtensions from "./passenger"; import notificationExtensions from "./notification"; +import tripAlert from "./trip-alert"; export default ({ strapi }) => { const extService = strapi.plugin("graphql").service("extension");

@@ -15,17 +16,9 @@ travelExtensions.forEach(extService.use);

vehicleExtensions.forEach(extService.use); passengerExtensions.forEach(extService.use); notificationExtensions.forEach(extService.use); + tripAlert.forEach(extService.use); // Disable shadow CRUD /// Fields extService.shadowCRUD("api::event.event").field("users").disableOutput(); - - /// Methods - extService.shadowCRUD("api::event.event").disableActions(["find"]); - extService.shadowCRUD("api::travel.travel").disableActions(["find"]); - extService.shadowCRUD("api::passenger.passenger").disableActions(["find"]); - extService.shadowCRUD("api::vehicle.vehicle").disableActions(["find"]); - extService - .shadowCRUD("plugin::users-permissions.user") - .disableActions(["find"]); };
A backend/src/graphql/trip-alert/index.ts

@@ -0,0 +1,112 @@

+import { errors } from "@strapi/utils"; + +export default [ + ({ nexus, strapi }) => ({ + types: [ + nexus.queryField("eventTripAlert", { + type: "TripAlertEntityResponse", + args: { + event: nexus.nonNull("ID"), + }, + }), + nexus.mutationField("setTripAlert", { + type: "TripAlertEntityResponse", + args: { + event: nexus.nonNull("ID"), + enabled: "Boolean", + latitude: "Float", + longitude: "Float", + distance: "Float", + address: "String", + }, + }), + ], + resolvers: { + Query: { + eventTripAlert: { + async resolve(_root, args, context) { + const user = context.state.user; + if (!user) throw new errors.ForbiddenError("No user found"); + + const [existingAlert] = await strapi.entityService.findMany( + "api::trip-alert.trip-alert", + { + filters: { + user: user.id, + event: args.event, + }, + } + ); + + const { toEntityResponse } = strapi + .plugin("graphql") + .service("format").returnTypes; + return toEntityResponse(existingAlert, { + args, + resourceUID: "api::trip-alert.trip-alert", + }); + }, + }, + }, + Mutation: { + setTripAlert: { + async resolve(_root, args, context) { + const user = context.state.user; + if (!user) throw new errors.ForbiddenError("No user found"); + + const [existingAlert] = await strapi.entityService.findMany( + "api::trip-alert.trip-alert", + { + filters: { + user: user.id, + event: args.event, + }, + populate: ["event"], + } + ); + + let tripAlert; + if (existingAlert) + tripAlert = await strapi.entityService.update( + "api::trip-alert.trip-alert", + existingAlert.id, + { + data: { + ...args, + event: existingAlert.event?.id, + user: user.id, + }, + } + ); + else + tripAlert = await strapi.entityService.create( + "api::trip-alert.trip-alert", + { + data: { + ...args, + user: user.id, + }, + } + ); + + const { toEntityResponse } = strapi + .plugin("graphql") + .service("format").returnTypes; + return toEntityResponse(tripAlert, { + args, + resourceUID: "api::trip-alert.trip-alert", + }); + }, + }, + }, + }, + resolversConfig: { + "Query.eventTripAlert": { + auth: true, + }, + "Mutation.setTripAlert": { + auth: true, + }, + }, + }), +];
A backend/src/utils/geography.ts

@@ -0,0 +1,24 @@

+export type Coordinates = [number, number]; + +export const calculateHaversineDistance = ( + [lat1, lon1]: Coordinates, + [lat2, lon2]: Coordinates +): number => { + const R = 6371; // Radius of the Earth in kilometers + + const dLat = degreesToRadians(lat2 - lat1); + const dLon = degreesToRadians(lon2 - lon1); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(degreesToRadians(lat1)) * + Math.cos(degreesToRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const distance = R * c; // Distance in kilometers + return distance; +}; + +const degreesToRadians = (degrees: number): number => degrees * (Math.PI / 180);
M backend/types/generated/contentTypes.d.tsbackend/types/generated/contentTypes.d.ts

@@ -1244,6 +1244,53 @@ Attribute.Private;

}; } +export interface ApiTripAlertTripAlert extends Schema.CollectionType { + collectionName: 'trip_alerts'; + info: { + singularName: 'trip-alert'; + pluralName: 'trip-alerts'; + displayName: 'TripAlert'; + description: ''; + }; + options: { + draftAndPublish: false; + }; + attributes: { + event: Attribute.Relation< + 'api::trip-alert.trip-alert', + 'oneToOne', + 'api::event.event' + >; + user: Attribute.Relation< + 'api::trip-alert.trip-alert', + 'oneToOne', + 'plugin::users-permissions.user' + >; + latitude: Attribute.Float; + longitude: Attribute.Float; + enabled: Attribute.Boolean & Attribute.DefaultTo<true>; + address: Attribute.String; + radius: Attribute.Float & + Attribute.SetMinMax<{ + min: 0; + }>; + createdAt: Attribute.DateTime; + updatedAt: Attribute.DateTime; + createdBy: Attribute.Relation< + 'api::trip-alert.trip-alert', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; + updatedBy: Attribute.Relation< + 'api::trip-alert.trip-alert', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; + }; +} + export interface ApiVehicleVehicle extends Schema.CollectionType { collectionName: 'vehicles'; info: {

@@ -1314,6 +1361,7 @@ 'api::page.page': ApiPagePage;

'api::passenger.passenger': ApiPassengerPassenger; 'api::setting.setting': ApiSettingSetting; 'api::travel.travel': ApiTravelTravel; + 'api::trip-alert.trip-alert': ApiTripAlertTripAlert; 'api::vehicle.vehicle': ApiVehicleVehicle; } }