From 68eb54995ff9b4f3bc39ac245b2668663878530c Mon Sep 17 00:00:00 2001
From: Bernard Ngandu <31113941+bernard-ng@users.noreply.github.com>
Date: Fri, 10 Oct 2025 14:55:36 +0200
Subject: [PATCH] Refactor point value object and add observability
---
client/package-lock.json | 58 +
client/package.json | 1 +
client/src/App.tsx | 115 +-
client/src/components/ui/toast.tsx | 116 +
client/src/components/ui/toaster.tsx | 37 +
client/src/components/ui/use-toast.ts | 129 +
client/src/hooks/useHotspotFeed.ts | 220 +-
client/src/hooks/useLeafletHeatmap.ts | 25 +-
client/src/hooks/useUserLocation.ts | 4 +-
client/src/locales/en/common.json | 7 +
client/src/locales/fr/common.json | 7 +
client/src/types/api.ts | 25 +-
server/.env | 24 +
server/compose.override.yaml | 7 +
server/compose.yaml | 30 +
server/composer.json | 7 +
server/composer.lock | 2078 ++++++++++++++++-
server/config/bundles.php | 3 +
server/config/packages/doctrine.yaml | 6 +
server/config/packages/mercure.yaml | 8 +
server/config/packages/messenger.yaml | 18 +
server/config/packages/monolog.yaml | 79 +
server/config/packages/prod/sentry.yaml | 6 +
server/config/packages/rate_limiter.yaml | 8 +
server/config/packages/validator.yaml | 11 +
server/config/services.yaml | 4 +
server/migrations/Version20251010102708.php | 36 +
server/src/Controller/SignalController.php | 109 +-
server/src/DataFixtures/SignalFixtures.php | 7 +-
server/src/Dto/SignalPayload.php | 14 -
server/src/Entity/Signal.php | 25 +-
.../src/Exception/InvalidPointException.php | 38 +
.../Exception/MissingClientKeyException.php | 17 +
server/src/Exception/PointTooFarException.php | 18 +
.../SubmissionRateLimitedException.php | 24 +
server/src/Message/SignalCreatedMessage.php | 12 +
.../SignalCreatedMessageHandler.php | 67 +
server/src/Payload/SignalPayload.php | 16 +
server/src/Service/ClientKeyProvider.php | 59 +
.../src/Service/PointProximityValidator.php | 52 +
server/src/Service/SignalSnapshotBuilder.php | 33 +-
server/src/Service/SignalSnapshotService.php | 60 +
.../src/Service/SignalSubmissionService.php | 95 +
server/src/ValueObject/Point.php | 66 +
server/symfony.lock | 57 +
.../tests/Functional/SignalControllerTest.php | 82 +-
46 files changed, 3691 insertions(+), 229 deletions(-)
create mode 100644 client/src/components/ui/toast.tsx
create mode 100644 client/src/components/ui/toaster.tsx
create mode 100644 client/src/components/ui/use-toast.ts
create mode 100644 server/compose.override.yaml
create mode 100644 server/compose.yaml
create mode 100644 server/config/packages/mercure.yaml
create mode 100644 server/config/packages/messenger.yaml
create mode 100644 server/config/packages/monolog.yaml
create mode 100644 server/config/packages/prod/sentry.yaml
create mode 100644 server/config/packages/rate_limiter.yaml
create mode 100644 server/config/packages/validator.yaml
create mode 100644 server/migrations/Version20251010102708.php
delete mode 100644 server/src/Dto/SignalPayload.php
create mode 100644 server/src/Exception/InvalidPointException.php
create mode 100644 server/src/Exception/MissingClientKeyException.php
create mode 100644 server/src/Exception/PointTooFarException.php
create mode 100644 server/src/Exception/SubmissionRateLimitedException.php
create mode 100644 server/src/Message/SignalCreatedMessage.php
create mode 100644 server/src/MessageHandler/SignalCreatedMessageHandler.php
create mode 100644 server/src/Payload/SignalPayload.php
create mode 100644 server/src/Service/ClientKeyProvider.php
create mode 100644 server/src/Service/PointProximityValidator.php
create mode 100644 server/src/Service/SignalSnapshotService.php
create mode 100644 server/src/Service/SignalSubmissionService.php
create mode 100644 server/src/ValueObject/Point.php
diff --git a/client/package-lock.json b/client/package-lock.json
index f1a105c..0657b05 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -13,6 +13,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-toast": "^1.2.15",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.5.3",
@@ -1156,6 +1157,40 @@
}
}
},
+ "node_modules/@radix-ui/react-toast": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
+ "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -1241,6 +1276,29 @@
}
}
},
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
+ "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-beta.41",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.41.tgz",
diff --git a/client/package.json b/client/package.json
index 2a2bb8c..5e344bb 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,6 +15,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-toast": "^1.2.15",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.5.3",
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 4f006f2..aa25579 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,5 +1,5 @@
-import { useCallback, useMemo, useState } from 'react'
-import { Layers, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Layers, Loader2, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { AppHeader } from '@/components/layout/AppHeader'
@@ -23,12 +23,39 @@ import { useHotspotFeed } from '@/hooks/useHotspotFeed'
import { useLeafletHeatmap, type TileProvider } from '@/hooks/useLeafletHeatmap'
import { useUserLocation } from '@/hooks/useUserLocation'
import { cn, distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils'
-import type { LatLng } from '@/types/api'
+import type { Point } from '@/types/api'
+import { Toaster } from '@/components/ui/toaster'
+import { useToast } from '@/components/ui/use-toast'
const RADIUS_KM = 1
+interface LocationGateProps {
+ title: string
+ message: string
+ actionLabel: string
+ onRetry: () => void
+ isLoading: boolean
+}
+
+function LocationGate({ title, message, actionLabel, onRetry, isLoading }: LocationGateProps) {
+ return (
+
+
+
+
+
+
+ )
+}
+
export default function App() {
- const [pendingSpot, setPendingSpot] = useState(null)
+ const [pendingSpot, setPendingSpot] = useState(null)
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [isConfirming, setIsConfirming] = useState(false)
const [tileProvider, setTileProvider] = useState('openstreetmap')
@@ -44,6 +71,7 @@ export default function App() {
}
return window.innerWidth < 768
})
+
const { t, i18n } = useTranslation()
const locale = i18n.language === 'fr' ? 'fr-FR' : 'en-US'
const distanceFormatter = useMemo(
@@ -63,6 +91,13 @@ export default function App() {
[t],
)
+ const {
+ location: userLocation,
+ error: locationError,
+ isRequesting: isRequestingLocation,
+ refresh: refreshLocation,
+ } = useUserLocation()
+
const {
status,
error,
@@ -73,9 +108,7 @@ export default function App() {
selectVisibleLatestByUser,
myLatestPoint,
lastUpdated,
- } = useHotspotFeed()
-
- const { location: userLocation, error: locationError, isRequesting: isRequestingLocation } = useUserLocation()
+ } = useHotspotFeed({ userLocation: userLocation ?? null })
const visibleDensity = useMemo(
() => selectVisibleDensity(userLocation ?? null),
@@ -102,7 +135,7 @@ export default function App() {
if (!myLatestPoint) {
return null
}
- if (userLocation && distanceInKm(userLocation, myLatestPoint) > RADIUS_KM) {
+ if (userLocation && distanceInKm(userLocation, myLatestPoint.signalLocation) > RADIUS_KM) {
return null
}
return myLatestPoint
@@ -135,12 +168,27 @@ export default function App() {
tileProvider,
})
+ const { toast } = useToast()
+
+ useEffect(() => {
+ if (!error) {
+ return
+ }
+ const description = error.detail ?? t(error.key, error.values ?? {})
+ toast({
+ variant: 'destructive',
+ title: t('errors.title'),
+ description,
+ duration: 6000,
+ })
+ }, [error, t, toast])
+
const handleConfirmSignal = useCallback(async () => {
if (!pendingSpot) {
return
}
setIsConfirming(true)
- const result = await submitPoint(pendingSpot.lat, pendingSpot.lng)
+ const result = await submitPoint(pendingSpot)
setIsConfirming(false)
if (result.success) {
setIsConfirmOpen(false)
@@ -164,7 +212,7 @@ export default function App() {
const handleFocusMySignal = useCallback(() => {
if (myVisibleSignal) {
- focusOn({ lat: myVisibleSignal.lat, lng: myVisibleSignal.lng }, 15)
+ focusOn({ lat: myVisibleSignal.signalLocation.lat, lng: myVisibleSignal.signalLocation.lng }, 15)
}
}, [focusOn, myVisibleSignal])
@@ -209,12 +257,12 @@ export default function App() {
.slice(0, 8)
.map((point) => {
const coordinates = t('common.coordinates', {
- lat: formatCoordinate(point.lat, locale),
- lng: formatCoordinate(point.lng, locale),
+ lat: formatCoordinate(point.signalLocation.lat, locale),
+ lng: formatCoordinate(point.signalLocation.lng, locale),
})
const distanceLabel = userLocation
? t('activityItem.distance', {
- distance: `${distanceFormatter.format(distanceInKm(userLocation, point))} km`,
+ distance: `${distanceFormatter.format(distanceInKm(userLocation, point.signalLocation))} km`,
})
: formatTimestamp(point.createdAt, locale)
return {
@@ -223,7 +271,7 @@ export default function App() {
subtitle: t('activityItem.user', { id: point.userKey.slice(0, 4).toUpperCase() }),
timestampLabel: formatRelativeTime(point.createdAt, locale),
distanceLabel,
- onFocus: () => focusOn({ lat: point.lat, lng: point.lng }, 15),
+ onFocus: () => focusOn({ lat: point.signalLocation.lat, lng: point.signalLocation.lng }, 15),
}
}),
[visiblePoints, userLocation, focusOn, distanceFormatter, t, locale],
@@ -240,10 +288,27 @@ export default function App() {
: 'translate-y-[calc(100%+1rem)] sm:translate-x-[calc(100%+2rem)]',
)
+ if (!userLocation) {
+ const gateTitle = t('location.gate.title')
+ const gateMessage = locationError ? t(locationError) : t('location.gate.description')
+ const gateAction = isRequestingLocation ? t('location.gate.loading') : t('location.gate.action')
+
+ return (
+
+ )
+ }
+
return (
-
-
+
+ {t('dialog.confirmSignal.longitude')}
{confirmationLng}°
+ {t('dialog.confirmSignal.reach', { radius: RADIUS_KM })}
-
- {t('dialog.confirmSignal.reach', { radius: RADIUS_KM })}
-
{t('dialog.confirmSignal.cancel')}
- {
- event.preventDefault()
- handleConfirmSignal().catch(() => undefined)
- }}
- >
+
{isConfirming ? t('dialog.confirmSignal.sending') : t('dialog.confirmSignal.confirm')}
-
+
+
+ >
)
}
diff --git a/client/src/components/ui/toast.tsx b/client/src/components/ui/toast.tsx
new file mode 100644
index 0000000..432ca88
--- /dev/null
+++ b/client/src/components/ui/toast.tsx
@@ -0,0 +1,116 @@
+import * as React from 'react'
+import * as ToastPrimitives from '@radix-ui/react-toast'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { X } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+const ToastProvider = ToastPrimitives.Provider
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName
+
+const toastVariants = cva(
+ 'group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-2xl border border-border bg-background p-4 shadow-lg transition-all',
+ {
+ variants: {
+ variant: {
+ default: 'bg-background text-foreground',
+ destructive: 'border-destructive/60 bg-destructive text-destructive-foreground',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ {props.children}
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+export type ToastProps = React.ComponentPropsWithoutRef
+export type ToastActionElement = React.ReactElement
+
+export {
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+}
diff --git a/client/src/components/ui/toaster.tsx b/client/src/components/ui/toaster.tsx
new file mode 100644
index 0000000..8ec63b7
--- /dev/null
+++ b/client/src/components/ui/toaster.tsx
@@ -0,0 +1,37 @@
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from '@/components/ui/toast'
+import { useToast } from '@/components/ui/use-toast'
+
+export function Toaster() {
+ const { toasts, dismiss } = useToast()
+
+ return (
+
+ {toasts.map(({ id, title, description, action, ...props }) => (
+ {
+ if (!open) {
+ dismiss(id)
+ }
+ }}
+ >
+
+ {title ? {title} : null}
+ {description ? {description} : null}
+
+ {action}
+
+
+ ))}
+
+
+ )
+}
diff --git a/client/src/components/ui/use-toast.ts b/client/src/components/ui/use-toast.ts
new file mode 100644
index 0000000..b6d02ae
--- /dev/null
+++ b/client/src/components/ui/use-toast.ts
@@ -0,0 +1,129 @@
+import * as React from 'react'
+
+import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
+
+type ToasterToast = ToastProps & {
+ id: string
+ title?: React.ReactNode
+ description?: React.ReactNode
+ action?: ToastActionElement
+}
+
+type ToastState = {
+ toasts: ToasterToast[]
+}
+
+type ToastAction =
+ | { type: 'ADD_TOAST'; toast: ToasterToast }
+ | { type: 'UPDATE_TOAST'; toast: Partial & { id: string } }
+ | { type: 'DISMISS_TOAST'; toastId?: string }
+ | { type: 'REMOVE_TOAST'; toastId?: string }
+
+const TOAST_LIMIT = 5
+const TOAST_REMOVE_DELAY = 1000
+
+const toastTimeouts = new Map>()
+
+const listeners = new Set<(state: ToastState) => void>()
+
+let memoryState: ToastState = { toasts: [] }
+
+function dispatch(action: ToastAction) {
+ memoryState = reducer(memoryState, action)
+ listeners.forEach((listener) => {
+ listener(memoryState)
+ })
+}
+
+function reducer(state: ToastState, action: ToastAction): ToastState {
+ switch (action.type) {
+ case 'ADD_TOAST': {
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ }
+ }
+ case 'UPDATE_TOAST': {
+ return {
+ ...state,
+ toasts: state.toasts.map((toast) =>
+ toast.id === action.toast.id ? { ...toast, ...action.toast } : toast,
+ ),
+ }
+ }
+ case 'DISMISS_TOAST': {
+ const { toastId } = action
+ if (toastId) {
+ addToRemoveQueue(toastId)
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id)
+ })
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((toast) =>
+ toast.id === toastId || toastId === undefined
+ ? { ...toast, open: false }
+ : toast,
+ ),
+ }
+ }
+ case 'REMOVE_TOAST': {
+ if (action.toastId === undefined) {
+ return { ...state, toasts: [] }
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((toast) => toast.id !== action.toastId),
+ }
+ }
+ default:
+ return state
+ }
+}
+
+function addToRemoveQueue(toastId: string) {
+ if (toastTimeouts.has(toastId)) {
+ return
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId)
+ dispatch({ type: 'REMOVE_TOAST', toastId })
+ }, TOAST_REMOVE_DELAY)
+
+ toastTimeouts.set(toastId, timeout)
+}
+
+function genId() {
+ return Math.random().toString(36).slice(2, 10)
+}
+
+export function useToast() {
+ const [state, setState] = React.useState(memoryState)
+
+ React.useEffect(() => {
+ listeners.add(setState)
+ return () => {
+ listeners.delete(setState)
+ }
+ }, [])
+
+ return {
+ ...state,
+ toast: (props: Omit) => {
+ const id = genId()
+ dispatch({ type: 'ADD_TOAST', toast: { ...props, id, open: true } })
+ return id
+ },
+ dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
+ }
+}
+
+export const toast = (props: Omit) => {
+ const id = genId()
+ dispatch({ type: 'ADD_TOAST', toast: { ...props, id, open: true } })
+ return id
+}
diff --git a/client/src/hooks/useHotspotFeed.ts b/client/src/hooks/useHotspotFeed.ts
index c817dda..31595f6 100644
--- a/client/src/hooks/useHotspotFeed.ts
+++ b/client/src/hooks/useHotspotFeed.ts
@@ -1,15 +1,40 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { distanceInKm } from '@/lib/utils'
-import type { ApiDensityCell, ApiPoint, ApiSnapshot, FeedStatus, LatLng } from '@/types/api'
+import type {
+ ApiDensityCell,
+ ApiPoint,
+ ApiSnapshot,
+ FeedStatus,
+ Point,
+ SnapshotEventPayload,
+} from '@/types/api'
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals'
const SNAPSHOT_LIMIT = 750
-const DEFAULT_REFRESH_MS = 7000
+const MERCURE_HUB = import.meta.env.VITE_MERCURE_HUB ?? 'http://localhost:3000/.well-known/mercure'
+const MERCURE_TOPIC = import.meta.env.VITE_MERCURE_TOPIC ?? 'https://points-of-interest.local/signals'
const VISIBLE_RADIUS_KM = 1
+type ProblemDetails = {
+ detail?: unknown
+}
+
+function extractProblemDetail(payload: unknown): string | null {
+ if (payload && typeof payload === 'object') {
+ const { detail } = payload as ProblemDetails
+ if (typeof detail === 'string' && detail.trim().length > 0) {
+ return detail
+ }
+ }
+ return null
+}
+
interface UseHotspotFeedOptions {
- autoRefreshMs?: number
+ userLocation: Point | null
+ snapshotLimit?: number
+ mercureHub?: string
+ mercureTopic?: string
}
interface SubmitResult {
@@ -19,9 +44,15 @@ interface SubmitResult {
export interface FeedError {
key: string
values?: Record
+ detail?: string
}
-export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) {
+export function useHotspotFeed({
+ userLocation,
+ snapshotLimit = SNAPSHOT_LIMIT,
+ mercureHub = MERCURE_HUB,
+ mercureTopic = MERCURE_TOPIC,
+}: UseHotspotFeedOptions) {
const [status, setStatus] = useState('loading')
const [error, setError] = useState(null)
const [rawPoints, setRawPoints] = useState([])
@@ -32,14 +63,51 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
const statusRef = useRef('loading')
const initialLoadRef = useRef(true)
+ const eventSourceRef = useRef(null)
const setStatusSafe = useCallback((next: FeedStatus) => {
statusRef.current = next
setStatus(next)
}, [])
+ const resetState = useCallback(() => {
+ setRawPoints([])
+ setRawDensity([])
+ setRawLatestByUser([])
+ setLastUpdated(null)
+ setClientKey(null)
+ setError(null)
+ eventSourceRef.current?.close()
+ eventSourceRef.current = null
+ initialLoadRef.current = true
+ setStatusSafe('loading')
+ }, [setStatusSafe])
+
+ const applySnapshot = useCallback(
+ (snapshot: ApiSnapshot, options?: { preserveClientKey?: boolean }) => {
+ setRawPoints(snapshot.points)
+ setRawDensity(snapshot.density)
+ setRawLatestByUser(snapshot.latestByUser)
+ if (!options?.preserveClientKey) {
+ setClientKey(snapshot.clientKey ?? null)
+ }
+ setLastUpdated(snapshot.updatedAt ?? new Date().toISOString())
+ setError(null)
+ initialLoadRef.current = false
+ if (statusRef.current !== 'posting') {
+ setStatusSafe('idle')
+ }
+ },
+ [setStatusSafe],
+ )
+
const fetchSnapshot = useCallback(
async (options?: { silent?: boolean }) => {
+ if (!userLocation) {
+ resetState()
+ return
+ }
+
const silent = options?.silent ?? false
const previousStatus = statusRef.current
const isInitial = initialLoadRef.current
@@ -53,26 +121,24 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
}
try {
- const response = await fetch(`${API_BASE}?limit=${SNAPSHOT_LIMIT}`, { cache: 'no-store' })
+ const response = await fetch(`${API_BASE}?limit=${snapshotLimit}`, { cache: 'no-store' })
if (!response.ok) {
- throw new Error('feed-unavailable')
+ const payload = await response.json().catch(() => null)
+ const detail = extractProblemDetail(payload)
+ setError({ key: 'errors.feedUnavailable', detail: detail ?? undefined })
+ if (initialLoadRef.current) {
+ setStatusSafe('error')
+ } else if (previousStatus !== 'posting') {
+ setStatusSafe('idle')
+ }
+ return
}
const data: ApiSnapshot = await response.json()
- setRawPoints(data.points ?? [])
- setRawDensity(data.density ?? [])
- setRawLatestByUser(data.latestByUser ?? [])
- setClientKey(data.clientKey ?? null)
- setLastUpdated(data.updatedAt ?? new Date().toISOString())
- setError(null)
- initialLoadRef.current = false
-
- const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle'
- setStatusSafe(nextStatus)
+ applySnapshot(data)
} catch (error) {
- const message = error instanceof Error ? error.message : null
- const key = message === 'feed-unavailable' ? 'errors.feedUnavailable' : 'errors.feedUnknown'
- setError({ key })
+ const detail = error instanceof Error && error.message ? error.message : null
+ setError({ key: 'errors.feedUnknown', detail: detail ?? undefined })
if (initialLoadRef.current) {
setStatusSafe('error')
} else if (previousStatus !== 'posting') {
@@ -80,39 +146,92 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
}
}
},
- [setStatusSafe],
+ [userLocation, snapshotLimit, resetState, setStatusSafe, applySnapshot],
)
- useEffect(() => {
- fetchSnapshot().catch(() => undefined)
- if (!autoRefreshMs) {
+ const connectToStream = useCallback(() => {
+ if (!userLocation) {
return
}
- const interval = window.setInterval(() => {
- fetchSnapshot({ silent: true }).catch(() => undefined)
- }, autoRefreshMs)
+ try {
+ const url = new URL(mercureHub)
+ url.searchParams.append('topic', mercureTopic)
+
+ const eventSource = new EventSource(url.toString())
+ eventSource.onmessage = (event) => {
+ try {
+ const payload: SnapshotEventPayload = JSON.parse(event.data)
+ if (payload?.type === 'snapshot' && payload.payload) {
+ applySnapshot(payload.payload, { preserveClientKey: true })
+ }
+ } catch (parseError) {
+ console.error('Failed to parse stream payload', parseError)
+ }
+ }
+
+ eventSource.onerror = () => {
+ if (statusRef.current !== 'loading') {
+ setError({ key: 'errors.feedUnknown' })
+ setStatusSafe('error')
+ }
+ }
+
+ eventSourceRef.current = eventSource
+ } catch (connectionError) {
+ console.error('Unable to subscribe to live updates', connectionError)
+ setError({ key: 'errors.feedUnavailable' })
+ setStatusSafe('error')
+ }
+ }, [applySnapshot, mercureHub, mercureTopic, setStatusSafe, userLocation])
+
+ useEffect(() => {
+ if (!userLocation) {
+ resetState()
+ return undefined
+ }
+
+ fetchSnapshot().catch(() => undefined)
+ connectToStream()
return () => {
- window.clearInterval(interval)
+ eventSourceRef.current?.close()
+ eventSourceRef.current = null
}
- }, [autoRefreshMs, fetchSnapshot])
+ }, [connectToStream, fetchSnapshot, resetState, userLocation])
const submitPoint = useCallback(
- async (lat: number, lng: number): Promise => {
+ async (target: Point): Promise => {
+ if (!userLocation) {
+ setError({
+ key: 'errors.submitWithReason',
+ values: { message: 'Location required before submitting.' },
+ detail: 'Location required before submitting.',
+ })
+ setStatusSafe('error')
+ return { success: false }
+ }
+
setStatusSafe('posting')
try {
const response = await fetch(API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ lat, lng }),
+ body: JSON.stringify({
+ signalLocation: target,
+ userLocation,
+ }),
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
- const message = payload?.message as string | undefined
- if (message) {
- setError({ key: 'errors.submitWithReason', values: { message } })
+ const detail = extractProblemDetail(payload)
+ if (detail) {
+ setError({
+ key: 'errors.submitWithReason',
+ values: { message: detail },
+ detail,
+ })
} else {
setError({ key: 'errors.submitUnavailable' })
}
@@ -120,14 +239,13 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
return { success: false }
}
- await fetchSnapshot({ silent: true })
setError(null)
setStatusSafe('idle')
return { success: true }
} catch (error) {
- const message = error instanceof Error ? error.message : null
- if (message) {
- setError({ key: 'errors.submitWithReason', values: { message } })
+ const detail = error instanceof Error && error.message ? error.message : null
+ if (detail) {
+ setError({ key: 'errors.submitWithReason', values: { message: detail }, detail })
} else {
setError({ key: 'errors.submitUnknown' })
}
@@ -135,13 +253,13 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
return { success: false }
}
},
- [fetchSnapshot, setStatusSafe],
+ [setStatusSafe, userLocation],
)
const hasClientKey = Boolean(clientKey)
- const filterWithinRadius = useCallback(
- (collection: T[], origin: LatLng | null) => {
+ const filterDensityWithinRadius = useCallback(
+ (collection: ApiDensityCell[], origin: Point | null) => {
if (!origin) {
return []
}
@@ -150,19 +268,29 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
[],
)
+ const filterPointsWithinRadius = useCallback(
+ (collection: ApiPoint[], origin: Point | null) => {
+ if (!origin) {
+ return []
+ }
+ return collection.filter((item) => distanceInKm(origin, item.signalLocation) <= VISIBLE_RADIUS_KM)
+ },
+ [],
+ )
+
const selectVisibleDensity = useCallback(
- (origin: LatLng | null) => filterWithinRadius(rawDensity, origin),
- [filterWithinRadius, rawDensity],
+ (origin: Point | null) => filterDensityWithinRadius(rawDensity, origin),
+ [filterDensityWithinRadius, rawDensity],
)
const selectVisiblePoints = useCallback(
- (origin: LatLng | null) => filterWithinRadius(rawPoints, origin),
- [filterWithinRadius, rawPoints],
+ (origin: Point | null) => filterPointsWithinRadius(rawPoints, origin),
+ [filterPointsWithinRadius, rawPoints],
)
const selectVisibleLatestByUser = useCallback(
- (origin: LatLng | null) => filterWithinRadius(rawLatestByUser, origin),
- [filterWithinRadius, rawLatestByUser],
+ (origin: Point | null) => filterPointsWithinRadius(rawLatestByUser, origin),
+ [filterPointsWithinRadius, rawLatestByUser],
)
const myLatestPoint = useMemo(() => {
diff --git a/client/src/hooks/useLeafletHeatmap.ts b/client/src/hooks/useLeafletHeatmap.ts
index 6daed31..7dc70ac 100644
--- a/client/src/hooks/useLeafletHeatmap.ts
+++ b/client/src/hooks/useLeafletHeatmap.ts
@@ -9,7 +9,7 @@ import L, {
} from 'leaflet'
import 'leaflet.heat'
-import type { ApiDensityCell, LatLng } from '@/types/api'
+import type { ApiDensityCell, Point } from '@/types/api'
type HeatPoint = [number, number, number?]
@@ -24,18 +24,24 @@ type LeafletWithHeat = typeof L & {
interface UseLeafletHeatmapParams {
heatCells: ApiDensityCell[]
- userLocation: LatLng | null
- onRequestSpot?: (position: LatLng) => void
+ userLocation: Point | null
+ onRequestSpot?: (position: Point) => void
tileProvider: TileProvider
}
interface UseLeafletHeatmapResult {
mapContainerRef: MutableRefObject
- focusOn: (position: LatLng, zoom?: number) => void
+ focusOn: (position: Point, zoom?: number) => void
fitToHeat: () => void
map: LeafletMap | null
}
+type TileSource = {
+ readonly url: string
+ readonly attribution: string
+ readonly options?: TileLayerOptions
+}
+
const TILE_SOURCES = {
openstreetmap: {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
@@ -49,11 +55,11 @@ const TILE_SOURCES = {
zoomOffset: -1,
} satisfies TileLayerOptions,
},
-} as const
+} as const satisfies Record
export type TileProvider = keyof typeof TILE_SOURCES
-const INITIAL_VIEW: LatLng = { lat: 20, lng: 0 }
+const INITIAL_VIEW: Point = { lat: 20, lng: 0 }
const DEFAULT_ZOOM = 3
export function useLeafletHeatmap({
@@ -74,12 +80,13 @@ export function useLeafletHeatmap({
const createTileLayer = useCallback(
(provider: TileProvider) => {
const leaflet = L as LeafletWithHeat
- const source = TILE_SOURCES[provider]
+ const source: TileSource = TILE_SOURCES[provider]
+ const options = source.options ?? {}
return leaflet.tileLayer(source.url, {
attribution: source.attribution,
crossOrigin: true,
maxZoom: 19,
- ...(source.options ?? {}),
+ ...options,
})
},
[],
@@ -248,7 +255,7 @@ export function useLeafletHeatmap({
}
}, [userLocation])
- const focusOn = useCallback((position: LatLng, zoom = 14) => {
+ const focusOn = useCallback((position: Point, zoom = 14) => {
const map = mapRef.current
if (!map) {
return
diff --git a/client/src/hooks/useUserLocation.ts b/client/src/hooks/useUserLocation.ts
index 376d00e..fd3f085 100644
--- a/client/src/hooks/useUserLocation.ts
+++ b/client/src/hooks/useUserLocation.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
-import type { LatLng } from '@/types/api'
+import type { Point } from '@/types/api'
function geolocationErrorMessage(error: GeolocationPositionError): string {
switch (error.code) {
@@ -16,7 +16,7 @@ function geolocationErrorMessage(error: GeolocationPositionError): string {
}
export function useUserLocation() {
- const [location, setLocation] = useState(null)
+ const [location, setLocation] = useState(null)
const [error, setError] = useState(null)
const [isRequesting, setIsRequesting] = useState(false)
const watchIdRef = useRef(null)
diff --git a/client/src/locales/en/common.json b/client/src/locales/en/common.json
index d9a55fb..65394a8 100644
--- a/client/src/locales/en/common.json
+++ b/client/src/locales/en/common.json
@@ -99,6 +99,12 @@
"timeout": "Timed out while fetching your location.",
"generic": "Failed to retrieve your location.",
"unsupported": "Geolocation is not supported in this browser."
+ },
+ "gate": {
+ "title": "Share your location to continue",
+ "description": "We need your location to personalise the live map feed.",
+ "action": "Try location again",
+ "loading": "Requesting location…"
}
},
"activityItem": {
@@ -128,6 +134,7 @@
"french": "Français"
},
"errors": {
+ "title": "Something went wrong",
"feedUnavailable": "Unable to reach the hotspot feed.",
"feedUnknown": "Unknown error while loading hotspots.",
"submitUnavailable": "Unable to store your signal.",
diff --git a/client/src/locales/fr/common.json b/client/src/locales/fr/common.json
index e8d4978..21bc045 100644
--- a/client/src/locales/fr/common.json
+++ b/client/src/locales/fr/common.json
@@ -99,6 +99,12 @@
"timeout": "Délai dépassé lors de la récupération de votre localisation.",
"generic": "Échec de la récupération de votre localisation.",
"unsupported": "La géolocalisation n'est pas prise en charge par ce navigateur."
+ },
+ "gate": {
+ "title": "Partagez votre position pour continuer",
+ "description": "Nous avons besoin de votre localisation pour personnaliser la carte en direct.",
+ "action": "Relancer la localisation",
+ "loading": "Demande de localisation…"
}
},
"activityItem": {
@@ -128,6 +134,7 @@
"french": "Français"
},
"errors": {
+ "title": "Une erreur est survenue",
"feedUnavailable": "Impossible d'atteindre le flux de signaux.",
"feedUnknown": "Erreur inconnue lors du chargement des signaux.",
"submitUnavailable": "Impossible d'enregistrer votre signal.",
diff --git a/client/src/types/api.ts b/client/src/types/api.ts
index 637334a..255e05f 100644
--- a/client/src/types/api.ts
+++ b/client/src/types/api.ts
@@ -1,9 +1,14 @@
export type FeedStatus = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing'
+export interface Point {
+ lat: number
+ lng: number
+}
+
export interface ApiPoint {
id: number
- lat: number
- lng: number
+ signalLocation: Point
+ userLocation: Point
createdAt: string
userKey: string
}
@@ -16,17 +21,17 @@ export interface ApiDensityCell {
export interface ApiSnapshot {
clientKey?: string
- points?: ApiPoint[]
- density?: ApiDensityCell[]
- latestByUser?: ApiPoint[]
- totals?: {
+ points: ApiPoint[]
+ density: ApiDensityCell[]
+ latestByUser: ApiPoint[]
+ totals: {
points: number
contributors: number
}
- updatedAt?: string
+ updatedAt: string
}
-export type LatLng = {
- lat: number
- lng: number
+export interface SnapshotEventPayload {
+ type: 'snapshot'
+ payload: ApiSnapshot
}
diff --git a/server/.env b/server/.env
index 4243c9e..f81eec7 100644
--- a/server/.env
+++ b/server/.env
@@ -35,3 +35,27 @@ DATABASE_URL="sqlite:///%kernel.project_dir%/var/points.sqlite"
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
+
+###> symfony/mercure-bundle ###
+# See https://symfony.com/doc/current/mercure.html#configuration
+# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
+MERCURE_URL=http://mercure:3000/.well-known/mercure
+# The public URL of the Mercure hub, used by the browser to connect
+MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure
+# The secret used to sign the JWTs
+MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
+# Topic used for live signal updates
+MERCURE_SIGNALS_TOPIC=https://points-of-interest.local/signals
+###< symfony/mercure-bundle ###
+
+###> symfony/messenger ###
+# Choose one of the transports below
+# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
+# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
+MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
+###< symfony/messenger ###
+
+###> sentry/sentry-symfony ###
+# Provide the DSN when deploying to production to enable error tracking
+SENTRY_DSN=
+###< sentry/sentry-symfony ###
diff --git a/server/compose.override.yaml b/server/compose.override.yaml
new file mode 100644
index 0000000..355576e
--- /dev/null
+++ b/server/compose.override.yaml
@@ -0,0 +1,7 @@
+
+services:
+###> symfony/mercure-bundle ###
+ mercure:
+ ports:
+ - "3000:80"
+###< symfony/mercure-bundle ###
diff --git a/server/compose.yaml b/server/compose.yaml
new file mode 100644
index 0000000..7379e84
--- /dev/null
+++ b/server/compose.yaml
@@ -0,0 +1,30 @@
+
+services:
+###> symfony/mercure-bundle ###
+ mercure:
+ image: dunglas/mercure
+ restart: unless-stopped
+ environment:
+ SERVER_NAME: ':80'
+ MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
+ MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
+ # Set the URL of your Symfony project (without trailing slash!) as value of the cors_origins directive
+ MERCURE_EXTRA_DIRECTIVES: |
+ cors_origins http://127.0.0.1:8000 http://localhost:8000 http://localhost:5173 http://127.0.0.1:5173
+ # Comment the following line to disable the development mode
+ command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
+ healthcheck:
+ test: ["CMD", "curl", "-f", "https://localhost/healthz"]
+ timeout: 5s
+ retries: 5
+ start_period: 60s
+ volumes:
+ - mercure_data:/data
+ - mercure_config:/config
+###< symfony/mercure-bundle ###
+
+volumes:
+###> symfony/mercure-bundle ###
+ mercure_data:
+ mercure_config:
+###< symfony/mercure-bundle ###
diff --git a/server/composer.json b/server/composer.json
index 4750b74..e5389ec 100644
--- a/server/composer.json
+++ b/server/composer.json
@@ -13,14 +13,21 @@
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.5",
"nelmio/cors-bundle": "^2.5",
+ "sentry/sentry-symfony": "^5.6",
"symfony/console": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "7.3.*",
+ "symfony/http-client": "^7.3",
+ "symfony/mercure-bundle": "^0.3.9",
+ "symfony/messenger": "^7.3",
+ "symfony/monolog-bundle": "^3.10",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
+ "symfony/rate-limiter": "^7.3",
"symfony/runtime": "7.3.*",
"symfony/serializer": "^7.3",
+ "symfony/validator": "^7.3",
"symfony/yaml": "7.3.*"
},
"config": {
diff --git a/server/composer.lock b/server/composer.lock
index 92d8f44..7d61f07 100644
--- a/server/composer.lock
+++ b/server/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "f70c6b9e063d78d2ada8ba72836ea674",
+ "content-hash": "6a8621d55b15988cb9bfd8c4d6494281",
"packages": [
{
"name": "doctrine/collections",
@@ -1298,6 +1298,422 @@
},
"time": "2025-01-24T11:45:48+00:00"
},
+ {
+ "name": "guzzlehttp/psr7",
+ "version": "2.8.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/psr7.git",
+ "reference": "21dc724a0583619cd1652f673303492272778051"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
+ "reference": "21dc724a0583619cd1652f673303492272778051",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1 || ^2.0",
+ "ralouphie/getallheaders": "^3.0"
+ },
+ "provide": {
+ "psr/http-factory-implementation": "1.0",
+ "psr/http-message-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "http-interop/http-factory-tests": "0.9.0",
+ "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+ },
+ "suggest": {
+ "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\Psr7\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://sagikazarmark.hu"
+ }
+ ],
+ "description": "PSR-7 message implementation that also provides common utility methods",
+ "keywords": [
+ "http",
+ "message",
+ "psr-7",
+ "request",
+ "response",
+ "stream",
+ "uri",
+ "url"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/psr7/issues",
+ "source": "https://github.com/guzzle/psr7/tree/2.8.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-23T21:21:41+00:00"
+ },
+ {
+ "name": "jean85/pretty-package-versions",
+ "version": "2.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Jean85/pretty-package-versions.git",
+ "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
+ "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
+ "shasum": ""
+ },
+ "require": {
+ "composer-runtime-api": "^2.1.0",
+ "php": "^7.4|^8.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.2",
+ "jean85/composer-provided-replaced-stub-package": "^1.0",
+ "phpstan/phpstan": "^2.0",
+ "phpunit/phpunit": "^7.5|^8.5|^9.6",
+ "rector/rector": "^2.0",
+ "vimeo/psalm": "^4.3 || ^5.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Jean85\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Alessandro Lai",
+ "email": "alessandro.lai85@gmail.com"
+ }
+ ],
+ "description": "A library to get pretty versions strings of installed dependencies",
+ "keywords": [
+ "composer",
+ "package",
+ "release",
+ "versions"
+ ],
+ "support": {
+ "issues": "https://github.com/Jean85/pretty-package-versions/issues",
+ "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
+ },
+ "time": "2025-03-19T14:43:43+00:00"
+ },
+ {
+ "name": "lcobucci/clock",
+ "version": "2.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/lcobucci/clock.git",
+ "reference": "fb533e093fd61321bfcbac08b131ce805fe183d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/lcobucci/clock/zipball/fb533e093fd61321bfcbac08b131ce805fe183d3",
+ "reference": "fb533e093fd61321bfcbac08b131ce805fe183d3",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.0",
+ "stella-maris/clock": "^0.1.4"
+ },
+ "require-dev": {
+ "infection/infection": "^0.26",
+ "lcobucci/coding-standard": "^8.0",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^0.12",
+ "phpstan/phpstan-deprecation-rules": "^0.12",
+ "phpstan/phpstan-phpunit": "^0.12",
+ "phpstan/phpstan-strict-rules": "^0.12",
+ "phpunit/phpunit": "^9.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Lcobucci\\Clock\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Luís Cobucci",
+ "email": "lcobucci@gmail.com"
+ }
+ ],
+ "description": "Yet another clock abstraction",
+ "support": {
+ "issues": "https://github.com/lcobucci/clock/issues",
+ "source": "https://github.com/lcobucci/clock/tree/2.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/lcobucci",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/lcobucci",
+ "type": "patreon"
+ }
+ ],
+ "time": "2022-04-19T19:34:17+00:00"
+ },
+ {
+ "name": "lcobucci/jwt",
+ "version": "4.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/lcobucci/jwt.git",
+ "reference": "55564265fddf810504110bd68ca311932324b0e9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/lcobucci/jwt/zipball/55564265fddf810504110bd68ca311932324b0e9",
+ "reference": "55564265fddf810504110bd68ca311932324b0e9",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "ext-openssl": "*",
+ "lcobucci/clock": "^2.0",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "infection/infection": "^0.20",
+ "lcobucci/coding-standard": "^6.0",
+ "mikey179/vfsstream": "^1.6",
+ "phpbench/phpbench": "^0.17",
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^0.12",
+ "phpstan/phpstan-deprecation-rules": "^0.12",
+ "phpstan/phpstan-phpunit": "^0.12",
+ "phpstan/phpstan-strict-rules": "^0.12",
+ "phpunit/php-invoker": "^3.1",
+ "phpunit/phpunit": "^9.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Lcobucci\\JWT\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Luís Cobucci",
+ "email": "lcobucci@gmail.com",
+ "role": "Developer"
+ }
+ ],
+ "description": "A simple library to work with JSON Web Token and JSON Web Signature",
+ "keywords": [
+ "JWS",
+ "jwt"
+ ],
+ "support": {
+ "issues": "https://github.com/lcobucci/jwt/issues",
+ "source": "https://github.com/lcobucci/jwt/tree/4.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/lcobucci",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/lcobucci",
+ "type": "patreon"
+ }
+ ],
+ "time": "2021-09-28T19:18:28+00:00"
+ },
+ {
+ "name": "monolog/monolog",
+ "version": "3.9.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Seldaek/monolog.git",
+ "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6",
+ "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/log": "^2.0 || ^3.0"
+ },
+ "provide": {
+ "psr/log-implementation": "3.0.0"
+ },
+ "require-dev": {
+ "aws/aws-sdk-php": "^3.0",
+ "doctrine/couchdb": "~1.0@dev",
+ "elasticsearch/elasticsearch": "^7 || ^8",
+ "ext-json": "*",
+ "graylog2/gelf-php": "^1.4.2 || ^2.0",
+ "guzzlehttp/guzzle": "^7.4.5",
+ "guzzlehttp/psr7": "^2.2",
+ "mongodb/mongodb": "^1.8",
+ "php-amqplib/php-amqplib": "~2.4 || ^3",
+ "php-console/php-console": "^3.1.8",
+ "phpstan/phpstan": "^2",
+ "phpstan/phpstan-deprecation-rules": "^2",
+ "phpstan/phpstan-strict-rules": "^2",
+ "phpunit/phpunit": "^10.5.17 || ^11.0.7",
+ "predis/predis": "^1.1 || ^2",
+ "rollbar/rollbar": "^4.0",
+ "ruflin/elastica": "^7 || ^8",
+ "symfony/mailer": "^5.4 || ^6",
+ "symfony/mime": "^5.4 || ^6"
+ },
+ "suggest": {
+ "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
+ "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
+ "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
+ "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
+ "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
+ "ext-mbstring": "Allow to work properly with unicode symbols",
+ "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
+ "ext-openssl": "Required to send log messages using SSL",
+ "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
+ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
+ "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
+ "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
+ "rollbar/rollbar": "Allow sending log messages to Rollbar",
+ "ruflin/elastica": "Allow sending log messages to an Elastic Search server"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Monolog\\": "src/Monolog"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "https://seld.be"
+ }
+ ],
+ "description": "Sends your logs to files, sockets, inboxes, databases and various web services",
+ "homepage": "https://github.com/Seldaek/monolog",
+ "keywords": [
+ "log",
+ "logging",
+ "psr-3"
+ ],
+ "support": {
+ "issues": "https://github.com/Seldaek/monolog/issues",
+ "source": "https://github.com/Seldaek/monolog/tree/3.9.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Seldaek",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-03-24T10:02:05+00:00"
+ },
{
"name": "nelmio/cors-bundle",
"version": "2.5.0",
@@ -1409,6 +1825,54 @@
},
"time": "2021-02-03T23:26:27+00:00"
},
+ {
+ "name": "psr/clock",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/clock.git",
+ "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
+ "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Psr\\Clock\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for reading the clock.",
+ "homepage": "https://github.com/php-fig/clock",
+ "keywords": [
+ "clock",
+ "now",
+ "psr",
+ "psr-20",
+ "time"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/clock/issues",
+ "source": "https://github.com/php-fig/clock/tree/1.0.0"
+ },
+ "time": "2022-11-25T14:36:26+00:00"
+ },
{
"name": "psr/container",
"version": "2.0.2",
@@ -1512,6 +1976,170 @@
},
"time": "2019-01-08T18:20:26+00:00"
},
+ {
+ "name": "psr/http-factory",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory"
+ },
+ "time": "2024-04-15T12:06:14+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/2.0"
+ },
+ "time": "2023-04-04T09:54:51+00:00"
+ },
+ {
+ "name": "psr/link",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/link.git",
+ "reference": "84b159194ecfd7eaa472280213976e96415433f7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/link/zipball/84b159194ecfd7eaa472280213976e96415433f7",
+ "reference": "84b159194ecfd7eaa472280213976e96415433f7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "suggest": {
+ "fig/link-util": "Provides some useful PSR-13 utilities"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Link\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interfaces for HTTP links",
+ "homepage": "https://github.com/php-fig/link",
+ "keywords": [
+ "http",
+ "http-link",
+ "link",
+ "psr",
+ "psr-13",
+ "rest"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/link/tree/2.0.1"
+ },
+ "time": "2021-03-11T23:00:27+00:00"
+ },
{
"name": "psr/log",
"version": "3.0.2",
@@ -1562,6 +2190,288 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
+ {
+ "name": "ralouphie/getallheaders",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ralouphie/getallheaders.git",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.1",
+ "phpunit/phpunit": "^5 || ^6.5"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/getallheaders.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ralph Khattar",
+ "email": "ralph.khattar@gmail.com"
+ }
+ ],
+ "description": "A polyfill for getallheaders.",
+ "support": {
+ "issues": "https://github.com/ralouphie/getallheaders/issues",
+ "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+ },
+ "time": "2019-03-08T08:55:37+00:00"
+ },
+ {
+ "name": "sentry/sentry",
+ "version": "4.16.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/getsentry/sentry-php.git",
+ "reference": "c5b086e4235762da175034bc463b0d31cbb38d2e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/c5b086e4235762da175034bc463b0d31cbb38d2e",
+ "reference": "c5b086e4235762da175034bc463b0d31cbb38d2e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-curl": "*",
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "guzzlehttp/psr7": "^1.8.4|^2.1.1",
+ "jean85/pretty-package-versions": "^1.5|^2.0.4",
+ "php": "^7.2|^8.0",
+ "psr/log": "^1.0|^2.0|^3.0",
+ "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0"
+ },
+ "conflict": {
+ "raven/raven": "*"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.4",
+ "guzzlehttp/promises": "^2.0.3",
+ "guzzlehttp/psr7": "^1.8.4|^2.1.1",
+ "monolog/monolog": "^1.6|^2.0|^3.0",
+ "phpbench/phpbench": "^1.0",
+ "phpstan/phpstan": "^1.3",
+ "phpunit/phpunit": "^8.5|^9.6",
+ "symfony/phpunit-bridge": "^5.2|^6.0|^7.0",
+ "vimeo/psalm": "^4.17"
+ },
+ "suggest": {
+ "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Sentry\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Sentry",
+ "email": "accounts@sentry.io"
+ }
+ ],
+ "description": "PHP SDK for Sentry (http://sentry.io)",
+ "homepage": "http://sentry.io",
+ "keywords": [
+ "crash-reporting",
+ "crash-reports",
+ "error-handler",
+ "error-monitoring",
+ "log",
+ "logging",
+ "profiling",
+ "sentry",
+ "tracing"
+ ],
+ "support": {
+ "issues": "https://github.com/getsentry/sentry-php/issues",
+ "source": "https://github.com/getsentry/sentry-php/tree/4.16.0"
+ },
+ "funding": [
+ {
+ "url": "https://sentry.io/",
+ "type": "custom"
+ },
+ {
+ "url": "https://sentry.io/pricing/",
+ "type": "custom"
+ }
+ ],
+ "time": "2025-09-22T13:38:03+00:00"
+ },
+ {
+ "name": "sentry/sentry-symfony",
+ "version": "5.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/getsentry/sentry-symfony.git",
+ "reference": "9867751f5091b55d7e3a223f48d88e228132e073"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/9867751f5091b55d7e3a223f48d88e228132e073",
+ "reference": "9867751f5091b55d7e3a223f48d88e228132e073",
+ "shasum": ""
+ },
+ "require": {
+ "guzzlehttp/psr7": "^2.1.1",
+ "jean85/pretty-package-versions": "^1.5||^2.0",
+ "php": "^7.2||^8.0",
+ "sentry/sentry": "^4.16.0",
+ "symfony/cache-contracts": "^1.1||^2.4||^3.0",
+ "symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/polyfill-php80": "^1.22",
+ "symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0"
+ },
+ "require-dev": {
+ "doctrine/dbal": "^2.13||^3.3||^4.0",
+ "doctrine/doctrine-bundle": "^2.6",
+ "friendsofphp/php-cs-fixer": "^2.19||^3.40",
+ "masterminds/html5": "^2.8",
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "1.12.5",
+ "phpstan/phpstan-phpunit": "1.4.0",
+ "phpstan/phpstan-symfony": "1.4.10",
+ "phpunit/phpunit": "^8.5.40||^9.6.21",
+ "symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/monolog-bundle": "^3.4",
+ "symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0",
+ "symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0",
+ "vimeo/psalm": "^4.3||^5.16.0"
+ },
+ "suggest": {
+ "doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
+ "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
+ "symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
+ "symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
+ },
+ "type": "symfony-bundle",
+ "autoload": {
+ "files": [
+ "src/aliases.php"
+ ],
+ "psr-4": {
+ "Sentry\\SentryBundle\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Sentry",
+ "email": "accounts@sentry.io"
+ }
+ ],
+ "description": "Symfony integration for Sentry (http://getsentry.com)",
+ "homepage": "http://getsentry.com",
+ "keywords": [
+ "errors",
+ "logging",
+ "sentry",
+ "symfony"
+ ],
+ "support": {
+ "issues": "https://github.com/getsentry/sentry-symfony/issues",
+ "source": "https://github.com/getsentry/sentry-symfony/tree/5.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://sentry.io/",
+ "type": "custom"
+ },
+ {
+ "url": "https://sentry.io/pricing/",
+ "type": "custom"
+ }
+ ],
+ "time": "2025-09-24T13:41:01+00:00"
+ },
+ {
+ "name": "stella-maris/clock",
+ "version": "0.1.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/stella-maris-solutions/clock.git",
+ "reference": "fa23ce16019289a18bb3446fdecd45befcdd94f8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/stella-maris-solutions/clock/zipball/fa23ce16019289a18bb3446fdecd45befcdd94f8",
+ "reference": "fa23ce16019289a18bb3446fdecd45befcdd94f8",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0|^8.0",
+ "psr/clock": "^1.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "StellaMaris\\Clock\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Andreas Heigl",
+ "role": "Maintainer"
+ }
+ ],
+ "description": "A pre-release of the proposed PSR-20 Clock-Interface",
+ "homepage": "https://gitlab.com/stella-maris/clock",
+ "keywords": [
+ "clock",
+ "datetime",
+ "point in time",
+ "psr20"
+ ],
+ "support": {
+ "source": "https://github.com/stella-maris-solutions/clock/tree/0.1.7"
+ },
+ "time": "2022-11-25T16:15:06+00:00"
+ },
{
"name": "symfony/cache",
"version": "v7.3.4",
@@ -1740,6 +2650,80 @@
],
"time": "2025-03-13T15:25:07+00:00"
},
+ {
+ "name": "symfony/clock",
+ "version": "v7.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/clock.git",
+ "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24",
+ "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/clock": "^1.0",
+ "symfony/polyfill-php83": "^1.28"
+ },
+ "provide": {
+ "psr/clock-implementation": "1.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/now.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\Clock\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Decouples applications from the system clock",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "clock",
+ "psr20",
+ "time"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/clock/tree/v7.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:21:43+00:00"
+ },
{
"name": "symfony/config",
"version": "v7.3.4",
@@ -2868,6 +3852,184 @@
],
"time": "2025-09-17T05:51:54+00:00"
},
+ {
+ "name": "symfony/http-client",
+ "version": "v7.3.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-client.git",
+ "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/4b62871a01c49457cf2a8e560af7ee8a94b87a62",
+ "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/log": "^1|^2|^3",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/http-client-contracts": "~3.4.4|^3.5.2",
+ "symfony/polyfill-php83": "^1.29",
+ "symfony/service-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "amphp/amp": "<2.5",
+ "amphp/socket": "<1.1",
+ "php-http/discovery": "<1.15",
+ "symfony/http-foundation": "<6.4"
+ },
+ "provide": {
+ "php-http/async-client-implementation": "*",
+ "php-http/client-implementation": "*",
+ "psr/http-client-implementation": "1.0",
+ "symfony/http-client-implementation": "3.0"
+ },
+ "require-dev": {
+ "amphp/http-client": "^4.2.1|^5.0",
+ "amphp/http-tunnel": "^1.0|^2.0",
+ "guzzlehttp/promises": "^1.4|^2.0",
+ "nyholm/psr7": "^1.0",
+ "php-http/httplug": "^1.0|^2.0",
+ "psr/http-client": "^1.0",
+ "symfony/amphp-http-client-meta": "^1.0|^2.0",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/http-kernel": "^6.4|^7.0",
+ "symfony/messenger": "^6.4|^7.0",
+ "symfony/process": "^6.4|^7.0",
+ "symfony/rate-limiter": "^6.4|^7.0",
+ "symfony/stopwatch": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\HttpClient\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "http"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/http-client/tree/v7.3.4"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-09-11T10:12:26+00:00"
+ },
+ {
+ "name": "symfony/http-client-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-client-contracts.git",
+ "reference": "75d7043853a42837e68111812f4d964b01e5101c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
+ "reference": "75d7043853a42837e68111812f4d964b01e5101c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\HttpClient\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to HTTP clients",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-04-29T11:18:49+00:00"
+ },
{
"name": "symfony/http-foundation",
"version": "v7.3.4",
@@ -3069,6 +4231,500 @@
],
"time": "2025-09-27T12:32:17+00:00"
},
+ {
+ "name": "symfony/mercure",
+ "version": "v0.6.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/mercure.git",
+ "reference": "304cf84609ef645d63adc65fc6250292909a461b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/mercure/zipball/304cf84609ef645d63adc65fc6250292909a461b",
+ "reference": "304cf84609ef645d63adc65fc6250292909a461b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1.3",
+ "symfony/deprecation-contracts": "^2.0|^3.0|^4.0",
+ "symfony/http-client": "^4.4|^5.0|^6.0|^7.0",
+ "symfony/http-foundation": "^4.4|^5.0|^6.0|^7.0",
+ "symfony/polyfill-php80": "^1.22",
+ "symfony/web-link": "^4.4|^5.0|^6.0|^7.0"
+ },
+ "require-dev": {
+ "lcobucci/jwt": "^3.4|^4.0|^5.0",
+ "symfony/event-dispatcher": "^4.4|^5.0|^6.0|^7.0",
+ "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0",
+ "symfony/phpunit-bridge": "^5.2|^6.0|^7.0",
+ "symfony/stopwatch": "^4.4|^5.0|^6.0|^7.0",
+ "twig/twig": "^2.0|^3.0|^4.0"
+ },
+ "suggest": {
+ "symfony/stopwatch": "Integration with the profiler performances"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/dunglas/mercure",
+ "name": "dunglas/mercure"
+ },
+ "branch-alias": {
+ "dev-main": "0.6.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Mercure\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Kévin Dunglas",
+ "email": "dunglas@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony Mercure Component",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "mercure",
+ "push",
+ "sse",
+ "updates"
+ ],
+ "support": {
+ "issues": "https://github.com/symfony/mercure/issues",
+ "source": "https://github.com/symfony/mercure/tree/v0.6.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/dunglas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/mercure",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-04-08T12:51:34+00:00"
+ },
+ {
+ "name": "symfony/mercure-bundle",
+ "version": "v0.3.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/mercure-bundle.git",
+ "reference": "77435d740b228e9f5f3f065b6db564f85f2cdb64"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/mercure-bundle/zipball/77435d740b228e9f5f3f065b6db564f85f2cdb64",
+ "reference": "77435d740b228e9f5f3f065b6db564f85f2cdb64",
+ "shasum": ""
+ },
+ "require": {
+ "lcobucci/jwt": "^3.4|^4.0|^5.0",
+ "php": ">=7.1.3",
+ "symfony/config": "^4.4|^5.0|^6.0|^7.0",
+ "symfony/dependency-injection": "^4.4|^5.4|^6.0|^7.0",
+ "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0",
+ "symfony/mercure": "^0.6.1",
+ "symfony/web-link": "^4.4|^5.0|^6.0|^7.0"
+ },
+ "require-dev": {
+ "symfony/phpunit-bridge": "^4.3.7|^5.0|^6.0|^7.0",
+ "symfony/stopwatch": "^4.3.7|^5.0|^6.0|^7.0",
+ "symfony/ux-turbo": "*",
+ "symfony/var-dumper": "^4.3.7|^5.0|^6.0|^7.0"
+ },
+ "suggest": {
+ "symfony/messenger": "To use the Messenger integration"
+ },
+ "type": "symfony-bundle",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "0.3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Bundle\\MercureBundle\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Kévin Dunglas",
+ "email": "dunglas@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony MercureBundle",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "mercure",
+ "push",
+ "sse",
+ "updates"
+ ],
+ "support": {
+ "issues": "https://github.com/symfony/mercure-bundle/issues",
+ "source": "https://github.com/symfony/mercure-bundle/tree/v0.3.9"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/dunglas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/mercure-bundle",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-05-31T09:07:18+00:00"
+ },
+ {
+ "name": "symfony/messenger",
+ "version": "v7.3.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/messenger.git",
+ "reference": "d9e04339404ba2dcd04c24172125516dc0e06c35"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/messenger/zipball/d9e04339404ba2dcd04c24172125516dc0e06c35",
+ "reference": "d9e04339404ba2dcd04c24172125516dc0e06c35",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/log": "^1|^2|^3",
+ "symfony/clock": "^6.4|^7.0",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "symfony/console": "<7.2",
+ "symfony/event-dispatcher": "<6.4",
+ "symfony/event-dispatcher-contracts": "<2.5",
+ "symfony/framework-bundle": "<6.4",
+ "symfony/http-kernel": "<6.4",
+ "symfony/lock": "<6.4",
+ "symfony/serializer": "<6.4"
+ },
+ "require-dev": {
+ "psr/cache": "^1.0|^2.0|^3.0",
+ "symfony/console": "^7.2",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/event-dispatcher": "^6.4|^7.0",
+ "symfony/http-kernel": "^6.4|^7.0",
+ "symfony/lock": "^6.4|^7.0",
+ "symfony/process": "^6.4|^7.0",
+ "symfony/property-access": "^6.4|^7.0",
+ "symfony/rate-limiter": "^6.4|^7.0",
+ "symfony/routing": "^6.4|^7.0",
+ "symfony/serializer": "^6.4|^7.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/stopwatch": "^6.4|^7.0",
+ "symfony/validator": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Messenger\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Samuel Roze",
+ "email": "samuel.roze@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Helps applications send and receive messages to/from other applications or via message queues",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/messenger/tree/v7.3.3"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-13T11:49:31+00:00"
+ },
+ {
+ "name": "symfony/monolog-bridge",
+ "version": "v7.3.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/monolog-bridge.git",
+ "reference": "7acf2abe23e5019451399ba69fc8ed3d61d4d8f0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/7acf2abe23e5019451399ba69fc8ed3d61d4d8f0",
+ "reference": "7acf2abe23e5019451399ba69fc8ed3d61d4d8f0",
+ "shasum": ""
+ },
+ "require": {
+ "monolog/monolog": "^3",
+ "php": ">=8.2",
+ "symfony/http-kernel": "^6.4|^7.0",
+ "symfony/service-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "symfony/console": "<6.4",
+ "symfony/http-foundation": "<6.4",
+ "symfony/security-core": "<6.4"
+ },
+ "require-dev": {
+ "symfony/console": "^6.4|^7.0",
+ "symfony/http-client": "^6.4|^7.0",
+ "symfony/mailer": "^6.4|^7.0",
+ "symfony/messenger": "^6.4|^7.0",
+ "symfony/mime": "^6.4|^7.0",
+ "symfony/security-core": "^6.4|^7.0",
+ "symfony/var-dumper": "^6.4|^7.0"
+ },
+ "type": "symfony-bridge",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Bridge\\Monolog\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides integration for Monolog with various Symfony components",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/monolog-bridge/tree/v7.3.4"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-09-24T16:45:39+00:00"
+ },
+ {
+ "name": "symfony/monolog-bundle",
+ "version": "v3.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/monolog-bundle.git",
+ "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181",
+ "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181",
+ "shasum": ""
+ },
+ "require": {
+ "monolog/monolog": "^1.25.1 || ^2.0 || ^3.0",
+ "php": ">=7.2.5",
+ "symfony/config": "^5.4 || ^6.0 || ^7.0",
+ "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0",
+ "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0",
+ "symfony/monolog-bridge": "^5.4 || ^6.0 || ^7.0"
+ },
+ "require-dev": {
+ "symfony/console": "^5.4 || ^6.0 || ^7.0",
+ "symfony/phpunit-bridge": "^6.3 || ^7.0",
+ "symfony/yaml": "^5.4 || ^6.0 || ^7.0"
+ },
+ "type": "symfony-bundle",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Bundle\\MonologBundle\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony MonologBundle",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "log",
+ "logging"
+ ],
+ "support": {
+ "issues": "https://github.com/symfony/monolog-bundle/issues",
+ "source": "https://github.com/symfony/monolog-bundle/tree/v3.10.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-11-06T17:08:13+00:00"
+ },
+ {
+ "name": "symfony/options-resolver",
+ "version": "v7.3.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/options-resolver.git",
+ "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d",
+ "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\OptionsResolver\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an improved replacement for the array_replace PHP function",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "config",
+ "configuration",
+ "options"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/options-resolver/tree/v7.3.3"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-05T10:16:07+00:00"
+ },
{
"name": "symfony/polyfill-intl-grapheme",
"version": "v1.33.0",
@@ -3651,6 +5307,163 @@
],
"time": "2025-09-15T13:55:54+00:00"
},
+ {
+ "name": "symfony/psr-http-message-bridge",
+ "version": "v7.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/psr-http-message-bridge.git",
+ "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f",
+ "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/http-message": "^1.0|^2.0",
+ "symfony/http-foundation": "^6.4|^7.0"
+ },
+ "conflict": {
+ "php-http/discovery": "<1.15",
+ "symfony/http-kernel": "<6.4"
+ },
+ "require-dev": {
+ "nyholm/psr7": "^1.1",
+ "php-http/discovery": "^1.15",
+ "psr/log": "^1.1.4|^2|^3",
+ "symfony/browser-kit": "^6.4|^7.0",
+ "symfony/config": "^6.4|^7.0",
+ "symfony/event-dispatcher": "^6.4|^7.0",
+ "symfony/framework-bundle": "^6.4|^7.0",
+ "symfony/http-kernel": "^6.4|^7.0"
+ },
+ "type": "symfony-bridge",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Bridge\\PsrHttpMessage\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "PSR HTTP message bridge",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr-17",
+ "psr-7"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-26T08:57:56+00:00"
+ },
+ {
+ "name": "symfony/rate-limiter",
+ "version": "v7.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/rate-limiter.git",
+ "reference": "7e855541d302ba752f86fb0e97932e7969fe9c04"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/7e855541d302ba752f86fb0e97932e7969fe9c04",
+ "reference": "7e855541d302ba752f86fb0e97932e7969fe9c04",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/options-resolver": "^7.3"
+ },
+ "require-dev": {
+ "psr/cache": "^1.0|^2.0|^3.0",
+ "symfony/lock": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\RateLimiter\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Wouter de Jong",
+ "email": "wouter@wouterj.nl"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides a Token Bucket implementation to rate limit input and output in your application",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "limiter",
+ "rate-limiter"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/rate-limiter/tree/v7.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-07-07T08:17:57+00:00"
+ },
{
"name": "symfony/routing",
"version": "v7.3.4",
@@ -4157,6 +5970,84 @@
],
"time": "2025-09-11T14:36:48+00:00"
},
+ {
+ "name": "symfony/translation-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/translation-contracts.git",
+ "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
+ "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Translation\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to translation",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-27T08:32:26+00:00"
+ },
{
"name": "symfony/type-info",
"version": "v7.3.4",
@@ -4240,6 +6131,108 @@
],
"time": "2025-09-11T15:33:27+00:00"
},
+ {
+ "name": "symfony/validator",
+ "version": "v7.3.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/validator.git",
+ "reference": "5e29a348b5fac2227b6938a54db006d673bb813a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/validator/zipball/5e29a348b5fac2227b6938a54db006d673bb813a",
+ "reference": "5e29a348b5fac2227b6938a54db006d673bb813a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/polyfill-php83": "^1.27",
+ "symfony/translation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "doctrine/lexer": "<1.1",
+ "symfony/dependency-injection": "<6.4",
+ "symfony/doctrine-bridge": "<7.0",
+ "symfony/expression-language": "<6.4",
+ "symfony/http-kernel": "<6.4",
+ "symfony/intl": "<6.4",
+ "symfony/property-info": "<6.4",
+ "symfony/translation": "<6.4.3|>=7.0,<7.0.3",
+ "symfony/yaml": "<6.4"
+ },
+ "require-dev": {
+ "egulias/email-validator": "^2.1.10|^3|^4",
+ "symfony/cache": "^6.4|^7.0",
+ "symfony/config": "^6.4|^7.0",
+ "symfony/console": "^6.4|^7.0",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/expression-language": "^6.4|^7.0",
+ "symfony/finder": "^6.4|^7.0",
+ "symfony/http-client": "^6.4|^7.0",
+ "symfony/http-foundation": "^6.4|^7.0",
+ "symfony/http-kernel": "^6.4|^7.0",
+ "symfony/intl": "^6.4|^7.0",
+ "symfony/mime": "^6.4|^7.0",
+ "symfony/property-access": "^6.4|^7.0",
+ "symfony/property-info": "^6.4|^7.0",
+ "symfony/string": "^6.4|^7.0",
+ "symfony/translation": "^6.4.3|^7.0.3",
+ "symfony/type-info": "^7.1.8",
+ "symfony/yaml": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Validator\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/",
+ "/Resources/bin/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides tools to validate values",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/validator/tree/v7.3.4"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-09-24T06:32:27+00:00"
+ },
{
"name": "symfony/var-dumper",
"version": "v7.3.4",
@@ -4408,6 +6401,89 @@
],
"time": "2025-09-11T10:12:26+00:00"
},
+ {
+ "name": "symfony/web-link",
+ "version": "v7.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/web-link.git",
+ "reference": "7697f74fce67555665339423ce453cc8216a98ff"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/web-link/zipball/7697f74fce67555665339423ce453cc8216a98ff",
+ "reference": "7697f74fce67555665339423ce453cc8216a98ff",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/link": "^1.1|^2.0"
+ },
+ "conflict": {
+ "symfony/http-kernel": "<6.4"
+ },
+ "provide": {
+ "psr/link-implementation": "1.0|2.0"
+ },
+ "require-dev": {
+ "symfony/http-kernel": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\WebLink\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Kévin Dunglas",
+ "email": "dunglas@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Manages links between resources",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "dns-prefetch",
+ "http",
+ "http2",
+ "link",
+ "performance",
+ "prefetch",
+ "preload",
+ "prerender",
+ "psr13",
+ "push"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/web-link/tree/v7.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-05-19T13:28:18+00:00"
+ },
{
"name": "symfony/yaml",
"version": "v7.3.3",
diff --git a/server/config/bundles.php b/server/config/bundles.php
index 1c8dfe8..39274c3 100644
--- a/server/config/bundles.php
+++ b/server/config/bundles.php
@@ -6,4 +6,7 @@ return [
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
+ Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
+ Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
+ Sentry\SentryBundle\SentryBundle::class => ['prod' => true],
];
diff --git a/server/config/packages/doctrine.yaml b/server/config/packages/doctrine.yaml
index 25138b9..f43116d 100644
--- a/server/config/packages/doctrine.yaml
+++ b/server/config/packages/doctrine.yaml
@@ -24,6 +24,12 @@ doctrine:
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
+ AppValueObject:
+ type: attribute
+ is_bundle: false
+ dir: '%kernel.project_dir%/src/ValueObject'
+ prefix: 'App\ValueObject'
+ alias: AppValueObject
controller_resolver:
auto_mapping: false
diff --git a/server/config/packages/mercure.yaml b/server/config/packages/mercure.yaml
new file mode 100644
index 0000000..f2a7395
--- /dev/null
+++ b/server/config/packages/mercure.yaml
@@ -0,0 +1,8 @@
+mercure:
+ hubs:
+ default:
+ url: '%env(MERCURE_URL)%'
+ public_url: '%env(MERCURE_PUBLIC_URL)%'
+ jwt:
+ secret: '%env(MERCURE_JWT_SECRET)%'
+ publish: '*'
diff --git a/server/config/packages/messenger.yaml b/server/config/packages/messenger.yaml
new file mode 100644
index 0000000..9214804
--- /dev/null
+++ b/server/config/packages/messenger.yaml
@@ -0,0 +1,18 @@
+framework:
+ messenger:
+ # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
+ # failure_transport: failed
+
+ transports:
+ sync: 'sync://'
+
+ routing:
+ App\Message\SignalCreatedMessage: sync
+
+# when@test:
+# framework:
+# messenger:
+# transports:
+# # replace with your transport name here (e.g., my_transport: 'in-memory://')
+# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
+# async: 'in-memory://'
diff --git a/server/config/packages/monolog.yaml b/server/config/packages/monolog.yaml
new file mode 100644
index 0000000..b69d8d8
--- /dev/null
+++ b/server/config/packages/monolog.yaml
@@ -0,0 +1,79 @@
+monolog:
+ channels:
+ - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
+ - signals
+
+when@dev:
+ monolog:
+ handlers:
+ main:
+ type: stream
+ path: "%kernel.logs_dir%/%kernel.environment%.log"
+ level: debug
+ channels: ["!event"]
+ signals:
+ type: stream
+ path: "%kernel.logs_dir%/signals_%kernel.environment%.log"
+ level: debug
+ channels: [signals]
+ # uncomment to get logging in your browser
+ # you may have to allow bigger header sizes in your Web server configuration
+ #firephp:
+ # type: firephp
+ # level: info
+ #chromephp:
+ # type: chromephp
+ # level: info
+ console:
+ type: console
+ process_psr_3_messages: false
+ channels: ["!event", "!doctrine", "!console"]
+
+when@test:
+ monolog:
+ handlers:
+ main:
+ type: fingers_crossed
+ action_level: error
+ handler: nested
+ excluded_http_codes: [404, 405]
+ channels: ["!event"]
+ nested:
+ type: stream
+ path: "%kernel.logs_dir%/%kernel.environment%.log"
+ level: debug
+ signals:
+ type: stream
+ path: "%kernel.logs_dir%/signals_%kernel.environment%.log"
+ level: debug
+ channels: [signals]
+
+when@prod:
+ monolog:
+ handlers:
+ main:
+ type: stream
+ path: php://stderr
+ level: info
+ formatter: monolog.formatter.json
+ channels: ["!event"]
+ signals:
+ type: stream
+ path: php://stderr
+ level: info
+ formatter: monolog.formatter.json
+ channels: [signals]
+ sentry:
+ type: sentry
+ level: error
+ hub_id: sentry
+ channels: ["!event", "!doctrine", "!console"]
+ console:
+ type: console
+ process_psr_3_messages: false
+ channels: ["!event", "!doctrine"]
+ deprecation:
+ type: stream
+ channels: [deprecation]
+ path: php://stderr
+ formatter: monolog.formatter.json
diff --git a/server/config/packages/prod/sentry.yaml b/server/config/packages/prod/sentry.yaml
new file mode 100644
index 0000000..b7edb78
--- /dev/null
+++ b/server/config/packages/prod/sentry.yaml
@@ -0,0 +1,6 @@
+sentry:
+ dsn: '%env(SENTRY_DSN)%'
+ register_error_listener: true
+ options:
+ environment: '%kernel.environment%'
+ traces_sample_rate: 0.0
diff --git a/server/config/packages/rate_limiter.yaml b/server/config/packages/rate_limiter.yaml
new file mode 100644
index 0000000..df4887a
--- /dev/null
+++ b/server/config/packages/rate_limiter.yaml
@@ -0,0 +1,8 @@
+framework:
+ rate_limiter:
+ signal_submission:
+ policy: 'token_bucket'
+ limit: 5
+ rate:
+ interval: '1 minute'
+ amount: 1
diff --git a/server/config/packages/validator.yaml b/server/config/packages/validator.yaml
new file mode 100644
index 0000000..dd47a6a
--- /dev/null
+++ b/server/config/packages/validator.yaml
@@ -0,0 +1,11 @@
+framework:
+ validation:
+ # Enables validator auto-mapping support.
+ # For instance, basic validation constraints will be inferred from Doctrine's metadata.
+ #auto_mapping:
+ # App\Entity\: []
+
+when@test:
+ framework:
+ validation:
+ not_compromised_password: false
diff --git a/server/config/services.yaml b/server/config/services.yaml
index f1113c2..36c6ed4 100644
--- a/server/config/services.yaml
+++ b/server/config/services.yaml
@@ -4,6 +4,9 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
+ app.signal_snapshot_limit: 750
+ app.max_signal_distance_km: 1
+ app.signal_stream_topic: '%env(string:MERCURE_SIGNALS_TOPIC)%'
services:
# default configuration for services in *this* file
@@ -23,3 +26,4 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
+
diff --git a/server/migrations/Version20251010102708.php b/server/migrations/Version20251010102708.php
new file mode 100644
index 0000000..cd7aad2
--- /dev/null
+++ b/server/migrations/Version20251010102708.php
@@ -0,0 +1,36 @@
+addSql('CREATE TABLE __temp__signals (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_key VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL, user_lat DOUBLE PRECISION NOT NULL, user_lng DOUBLE PRECISION NOT NULL, signal_lat DOUBLE PRECISION NOT NULL, signal_lng DOUBLE PRECISION NOT NULL)');
+ $this->addSql('INSERT INTO __temp__signals (id, user_key, created_at, user_lat, user_lng, signal_lat, signal_lng) SELECT id, user_key, created_at, lat, lng, lat, lng FROM signals');
+ $this->addSql('DROP TABLE signals');
+ $this->addSql('ALTER TABLE __temp__signals RENAME TO signals');
+ $this->addSql('CREATE INDEX idx_signals_created_at ON signals (created_at)');
+ $this->addSql('CREATE INDEX idx_signals_user_key ON signals (user_key)');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('CREATE TABLE __temp__signals (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_key VARCHAR(64) NOT NULL, lat DOUBLE PRECISION NOT NULL, lng DOUBLE PRECISION NOT NULL, created_at DATETIME NOT NULL)');
+ $this->addSql('INSERT INTO __temp__signals (id, user_key, lat, lng, created_at) SELECT id, user_key, signal_lat, signal_lng, created_at FROM signals');
+ $this->addSql('DROP TABLE signals');
+ $this->addSql('ALTER TABLE __temp__signals RENAME TO signals');
+ $this->addSql('CREATE INDEX idx_signals_created_at ON signals (created_at)');
+ $this->addSql('CREATE INDEX idx_signals_user_key ON signals (user_key)');
+ }
+}
diff --git a/server/src/Controller/SignalController.php b/server/src/Controller/SignalController.php
index 50db74f..96aace0 100644
--- a/server/src/Controller/SignalController.php
+++ b/server/src/Controller/SignalController.php
@@ -4,14 +4,13 @@ declare(strict_types=1);
namespace App\Controller;
-use App\Dto\SignalPayload;
-use App\Entity\Signal;
-use App\Repository\SignalRepository;
-use App\Service\SignalSnapshotBuilder;
-use DateTimeImmutable;
-use DateTimeZone;
+use App\Payload\SignalPayload;
+use App\Service\ClientKeyProvider;
+use App\Service\SignalSnapshotService;
+use App\Service\SignalSubmissionService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Annotation\Route;
@@ -20,104 +19,46 @@ use Symfony\Component\Routing\Annotation\Route;
class SignalController
{
public function __construct(
- private readonly SignalRepository $signals,
- private readonly SignalSnapshotBuilder $snapshotBuilder,
+ private readonly SignalSnapshotService $snapshotService,
+ private readonly SignalSubmissionService $submissionService,
+ private readonly ClientKeyProvider $clientKeyProvider,
) {
}
#[Route(path: '', name: 'api_signals_index', methods: ['GET'])]
public function index(Request $request, #[MapQueryParameter('limit', flags: FILTER_NULL_ON_FAILURE)] ?int $limit = null): JsonResponse
{
- $limit = $this->normalizeLimit($limit);
+ $clientKey = $this->clientKeyProvider->fromRequest($request);
+ $snapshot = $this->snapshotService->buildSnapshot($limit, $clientKey);
- $clientKey = $this->hashIp($this->extractClientIp($request));
-
- $signals = $this->signals->findRecent($limit);
- $snapshot = $this->snapshotBuilder->build($signals);
-
- $payload = [
- 'clientKey' => $clientKey,
- 'points' => $snapshot['points'],
- 'density' => $snapshot['density'],
- 'latestByUser' => $snapshot['latestByUser'],
- 'totals' => $snapshot['totals'],
- 'updatedAt' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format(DATE_ATOM),
- ];
-
- return new JsonResponse($payload);
+ return new JsonResponse($snapshot);
}
#[Route(path: '', name: 'api_signals_store', methods: ['POST'])]
- public function store(#[MapRequestPayload(validationFailedStatusCode: JsonResponse::HTTP_UNPROCESSABLE_ENTITY)] SignalPayload $payload, Request $request): JsonResponse
+ public function store(Request $request, #[MapRequestPayload] SignalPayload $payload): JsonResponse
{
- $lat = $payload->lat;
- $lng = $payload->lng;
+ $clientKey = $this->clientKeyProvider->fromRequest($request);
+ $signal = $this->submissionService->submit($clientKey, $payload);
- if (! is_finite($lat) || ! is_finite($lng)) {
- return $this->errorResponse('invalid_coordinates', 'Latitude and longitude must be numbers.', JsonResponse::HTTP_UNPROCESSABLE_ENTITY);
- }
-
- if ($lat < -90 || $lat > 90 || $lng < -180 || $lng > 180) {
- return $this->errorResponse('out_of_bounds', 'Latitude or longitude out of range.', JsonResponse::HTTP_UNPROCESSABLE_ENTITY);
- }
-
- $clientIp = $this->extractClientIp($request);
- $clientKey = $this->hashIp($clientIp);
-
- $signal = new Signal()
- ->setUserKey($clientKey)
- ->setLat($lat)
- ->setLng($lng)
- ->setCreatedAt(new DateTimeImmutable('now', new DateTimeZone('UTC')));
-
- $this->signals->save($signal);
+ $signalLocation = $signal->getSignalLocation();
+ $userLocation = $signal->getUserLocation();
return new JsonResponse([
'status' => 'stored',
'clientKey' => $clientKey,
'point' => [
'id' => $signal->getId(),
- 'lat' => $signal->getLat(),
- 'lng' => $signal->getLng(),
+ 'signalLocation' => [
+ 'lat' => $signalLocation->getLat(),
+ 'lng' => $signalLocation->getLng(),
+ ],
+ 'userLocation' => [
+ 'lat' => $userLocation->getLat(),
+ 'lng' => $userLocation->getLng(),
+ ],
'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM),
'userKey' => $signal->getUserKey(),
],
- ], JsonResponse::HTTP_CREATED);
- }
-
- private function errorResponse(string $error, string $message, int $statusCode): JsonResponse
- {
- return new JsonResponse([
- 'error' => $error,
- 'message' => $message,
- ], $statusCode);
- }
-
- private function normalizeLimit(?int $limit): int
- {
- $limit ??= 750;
- if ($limit < 1 || $limit > 5000) {
- return 750;
- }
-
- return $limit;
- }
-
- private function extractClientIp(Request $request): string
- {
- $forwarded = $request->headers->get('X-Forwarded-For');
- if ($forwarded !== null && $forwarded !== '') {
- $parts = array_filter(array_map('trim', explode(',', $forwarded)));
- if ($parts !== []) {
- return $parts[0];
- }
- }
-
- return $request->getClientIp() ?? '0.0.0.0';
- }
-
- private function hashIp(string $ip): string
- {
- return substr(hash('sha256', $ip), 0, 12);
+ ], Response::HTTP_CREATED);
}
}
diff --git a/server/src/DataFixtures/SignalFixtures.php b/server/src/DataFixtures/SignalFixtures.php
index 3cb2714..28e9d15 100644
--- a/server/src/DataFixtures/SignalFixtures.php
+++ b/server/src/DataFixtures/SignalFixtures.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\DataFixtures;
+use App\ValueObject\Point;
use App\Entity\Signal;
use DateInterval;
use DateTimeImmutable;
@@ -38,10 +39,12 @@ class SignalFixtures extends Fixture
];
foreach ($coordinates as $config) {
+ $signalLocation = Point::fromLatLng($config['lat'], $config['lng']);
+
$signal = new Signal()
->setUserKey($config['user'])
- ->setLat($config['lat'])
- ->setLng($config['lng'])
+ ->setUserLocation(Point::fromLatLng($config['lat'], $config['lng']))
+ ->setSignalLocation($signalLocation)
->setCreatedAt($baseTime->add(new DateInterval(sprintf('PT%dM', $config['offset']))));
$manager->persist($signal);
diff --git a/server/src/Dto/SignalPayload.php b/server/src/Dto/SignalPayload.php
deleted file mode 100644
index 5c9c9a6..0000000
--- a/server/src/Dto/SignalPayload.php
+++ /dev/null
@@ -1,14 +0,0 @@
-lat;
+ return $this->userLocation;
}
- public function setLat(float $lat): self
+ public function setUserLocation(Point $userLocation): self
{
- $this->lat = $lat;
+ $this->userLocation = $userLocation;
return $this;
}
- public function getLng(): float
+ public function getSignalLocation(): Point
{
- return $this->lng;
+ return $this->signalLocation;
}
- public function setLng(float $lng): self
+ public function setSignalLocation(Point $signalLocation): self
{
- $this->lng = $lng;
+ $this->signalLocation = $signalLocation;
return $this;
}
diff --git a/server/src/Exception/InvalidPointException.php b/server/src/Exception/InvalidPointException.php
new file mode 100644
index 0000000..4b15d87
--- /dev/null
+++ b/server/src/Exception/InvalidPointException.php
@@ -0,0 +1,38 @@
+ 'invalid_coordinates'])]
+final class InvalidPointException extends \RuntimeException
+{
+ private function __construct(string $message)
+ {
+ parent::__construct($message);
+ }
+
+ public static function invalidNumber(): self
+ {
+ return new self('Latitude and longitude must be finite numbers.');
+ }
+
+ public static function latitudeOutOfRange(float $lat): self
+ {
+ return new self(sprintf('Latitude %.6f is out of bounds.', $lat));
+ }
+
+ public static function longitudeOutOfRange(float $lng): self
+ {
+ return new self(sprintf('Longitude %.6f is out of bounds.', $lng));
+ }
+
+ public static function missingCoordinates(): self
+ {
+ return new self('Missing latitude or longitude.');
+ }
+}
diff --git a/server/src/Exception/MissingClientKeyException.php b/server/src/Exception/MissingClientKeyException.php
new file mode 100644
index 0000000..64103b1
--- /dev/null
+++ b/server/src/Exception/MissingClientKeyException.php
@@ -0,0 +1,17 @@
+ 'missing_client_key'])]
+final class MissingClientKeyException extends \RuntimeException
+{
+ public function __construct()
+ {
+ parent::__construct('Unable to determine your network address.');
+ }
+}
diff --git a/server/src/Exception/PointTooFarException.php b/server/src/Exception/PointTooFarException.php
new file mode 100644
index 0000000..9138ed7
--- /dev/null
+++ b/server/src/Exception/PointTooFarException.php
@@ -0,0 +1,18 @@
+ 'point_too_far'])]
+final class PointTooFarException extends \RuntimeException
+{
+ public function __construct(float $maximumDistanceKm)
+ {
+ parent::__construct(sprintf('The submitted point must be within %.2fkm of your location.', $maximumDistanceKm));
+ }
+}
diff --git a/server/src/Exception/SubmissionRateLimitedException.php b/server/src/Exception/SubmissionRateLimitedException.php
new file mode 100644
index 0000000..36b7ec8
--- /dev/null
+++ b/server/src/Exception/SubmissionRateLimitedException.php
@@ -0,0 +1,24 @@
+ 'submission_rate_limited'])]
+final class SubmissionRateLimitedException extends \RuntimeException
+{
+ public function __construct(?DateTimeInterface $retryAfter)
+ {
+ $message = 'Too many submissions from your network. Please wait before trying again.';
+ if ($retryAfter !== null) {
+ $message .= sprintf(' You can retry after %s.', $retryAfter->format(DATE_ATOM));
+ }
+
+ parent::__construct($message);
+ }
+}
diff --git a/server/src/Message/SignalCreatedMessage.php b/server/src/Message/SignalCreatedMessage.php
new file mode 100644
index 0000000..41a9623
--- /dev/null
+++ b/server/src/Message/SignalCreatedMessage.php
@@ -0,0 +1,12 @@
+signals->findRecent($this->snapshotLimit);
+ $snapshot = $this->snapshotBuilder->build($recentSignals);
+
+ $payload = [
+ 'type' => 'snapshot',
+ 'payload' => [
+ 'points' => $snapshot['points'],
+ 'density' => $snapshot['density'],
+ 'latestByUser' => $snapshot['latestByUser'],
+ 'totals' => $snapshot['totals'],
+ 'updatedAt' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_ATOM),
+ ],
+ ];
+
+ $data = json_encode($payload, JSON_THROW_ON_ERROR);
+
+ $update = new Update(
+ topics: $this->topic,
+ data: $data,
+ );
+
+ try {
+ $this->hub->publish($update);
+ } catch (Throwable $exception) {
+ $this->logger?->error('Failed to publish signal update to Mercure.', [
+ 'exception' => $exception,
+ ]);
+ }
+ }
+}
diff --git a/server/src/Payload/SignalPayload.php b/server/src/Payload/SignalPayload.php
new file mode 100644
index 0000000..d874603
--- /dev/null
+++ b/server/src/Payload/SignalPayload.php
@@ -0,0 +1,16 @@
+extractClientIp($request);
+
+ $clientKey = $this->hashIp($ip);
+ $this->logger->debug('Generated client key for request.', [
+ 'client_key' => $clientKey,
+ ]);
+
+ return $clientKey;
+ }
+
+ private function extractClientIp(Request $request): string
+ {
+ $forwarded = $request->headers->get('X-Forwarded-For');
+ if ($forwarded !== null && $forwarded !== '') {
+ $parts = array_filter(array_map('trim', explode(',', $forwarded)));
+ if ($parts !== []) {
+ $this->logger->info('Resolved client address from forwarded header.', [
+ 'resolution' => 'forwarded',
+ ]);
+ return $parts[0];
+ }
+ }
+
+ $ip = $request->getClientIp();
+ if (is_string($ip) && $ip !== '') {
+ $this->logger->info('Resolved client address from remote address.', [
+ 'resolution' => 'remote',
+ ]);
+ return $ip;
+ }
+
+ $this->logger->critical('Failed to resolve client address for key generation.');
+ throw new MissingClientKeyException();
+ }
+
+ private function hashIp(string $ip): string
+ {
+ return substr(hash('sha256', $ip), 0, 12);
+ }
+}
diff --git a/server/src/Service/PointProximityValidator.php b/server/src/Service/PointProximityValidator.php
new file mode 100644
index 0000000..5389515
--- /dev/null
+++ b/server/src/Service/PointProximityValidator.php
@@ -0,0 +1,52 @@
+distanceInKm($userLocation, $signalLocation);
+
+ $this->logger->debug('Calculated proximity between user and signal.', [
+ 'distance_km' => $distance,
+ 'max_distance_km' => $this->maximumDistanceKm,
+ ]);
+
+ if ($distance > $this->maximumDistanceKm) {
+ $this->logger->warning('Rejected signal because it is beyond the allowed proximity.', [
+ 'distance_km' => $distance,
+ 'max_distance_km' => $this->maximumDistanceKm,
+ ]);
+ throw new PointTooFarException($this->maximumDistanceKm);
+ }
+ }
+
+ private function distanceInKm(Point $a, Point $b): float
+ {
+ $lat1 = deg2rad($a->getLat());
+ $lat2 = deg2rad($b->getLat());
+ $deltaLat = deg2rad($b->getLat() - $a->getLat());
+ $deltaLng = deg2rad($b->getLng() - $a->getLng());
+
+ $haversine = sin($deltaLat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($deltaLng / 2) ** 2;
+ $c = 2 * atan2(sqrt($haversine), sqrt(1 - $haversine));
+
+ return 6371 * $c;
+ }
+}
diff --git a/server/src/Service/SignalSnapshotBuilder.php b/server/src/Service/SignalSnapshotBuilder.php
index 821d453..b2986b5 100644
--- a/server/src/Service/SignalSnapshotBuilder.php
+++ b/server/src/Service/SignalSnapshotBuilder.php
@@ -12,9 +12,21 @@ class SignalSnapshotBuilder
* @param list $signals
*
* @return array{
- * points: list,
+ * points: list,
* density: list,
- * latestByUser: list,
+ * latestByUser: list,
* totals: array{points: int, contributors: int}
* }
*/
@@ -25,18 +37,27 @@ class SignalSnapshotBuilder
$latestByUser = [];
foreach ($signals as $signal) {
+ $signalLocation = $signal->getSignalLocation();
+ $userLocation = $signal->getUserLocation();
+
$point = [
'id' => (int) $signal->getId(),
- 'lat' => $signal->getLat(),
- 'lng' => $signal->getLng(),
+ 'signalLocation' => [
+ 'lat' => $signalLocation->getLat(),
+ 'lng' => $signalLocation->getLng(),
+ ],
+ 'userLocation' => [
+ 'lat' => $userLocation->getLat(),
+ 'lng' => $userLocation->getLng(),
+ ],
'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM),
'userKey' => $signal->getUserKey(),
];
$points[] = $point;
- $bucketLat = round($signal->getLat(), 3);
- $bucketLng = round($signal->getLng(), 3);
+ $bucketLat = round($signalLocation->getLat(), 3);
+ $bucketLng = round($signalLocation->getLng(), 3);
$bucketKey = $bucketLat . ':' . $bucketLng;
if (! isset($densityBuckets[$bucketKey])) {
diff --git a/server/src/Service/SignalSnapshotService.php b/server/src/Service/SignalSnapshotService.php
new file mode 100644
index 0000000..c976264
--- /dev/null
+++ b/server/src/Service/SignalSnapshotService.php
@@ -0,0 +1,60 @@
+normalizeLimit($limit);
+ $signals = $this->signals->findRecent($resolvedLimit);
+ $snapshot = $this->snapshotBuilder->build($signals);
+
+ $this->logger->info('Built signal snapshot for client.', [
+ 'client_key' => $clientKey,
+ 'requested_limit' => $limit,
+ 'applied_limit' => $resolvedLimit,
+ 'point_count' => count($snapshot['points']),
+ ]);
+
+ return [
+ 'clientKey' => $clientKey,
+ 'points' => $snapshot['points'],
+ 'density' => $snapshot['density'],
+ 'latestByUser' => $snapshot['latestByUser'],
+ 'totals' => $snapshot['totals'],
+ 'updatedAt' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format(DATE_ATOM),
+ ];
+ }
+
+ private function normalizeLimit(?int $limit): int
+ {
+ if ($limit === null) {
+ return $this->snapshotLimit;
+ }
+
+ if ($limit < 1) {
+ return $this->snapshotLimit;
+ }
+
+ return min($limit, $this->snapshotLimit);
+ }
+}
diff --git a/server/src/Service/SignalSubmissionService.php b/server/src/Service/SignalSubmissionService.php
new file mode 100644
index 0000000..22a7818
--- /dev/null
+++ b/server/src/Service/SignalSubmissionService.php
@@ -0,0 +1,95 @@
+enforceRateLimit($clientKey);
+ $signalLocation = $payload->signalLocation;
+ $userLocation = $payload->userLocation;
+
+ $this->proximityValidator->assertWithinRange($userLocation, $signalLocation);
+
+ $this->logger->info('Storing new signal submission.', [
+ 'client_key' => $clientKey,
+ 'signal_location' => [
+ 'lat' => round($signalLocation->getLat(), 5),
+ 'lng' => round($signalLocation->getLng(), 5),
+ ],
+ 'user_location' => [
+ 'lat' => round($userLocation->getLat(), 5),
+ 'lng' => round($userLocation->getLng(), 5),
+ ],
+ ]);
+
+ $signal = (new Signal())
+ ->setUserKey($clientKey)
+ ->setUserLocation($userLocation)
+ ->setSignalLocation($signalLocation)
+ ->setCreatedAt(new DateTimeImmutable('now', new DateTimeZone('UTC')));
+
+ $this->signals->save($signal);
+
+ $signalId = $signal->getId();
+ if ($signalId !== null) {
+ $this->logger->debug('Dispatching signal created message.', [
+ 'signal_id' => $signalId,
+ ]);
+ $this->bus->dispatch(new SignalCreatedMessage($signalId));
+ $this->logger->info('Signal stored successfully.', [
+ 'signal_id' => $signalId,
+ 'client_key' => $clientKey,
+ ]);
+ }
+
+ return $signal;
+ }
+
+ private function enforceRateLimit(string $clientKey): void
+ {
+ $rateLimiter = $this->submissionLimiter->create($clientKey);
+ $limit = $rateLimiter->consume(1);
+
+ if (! $limit->isAccepted()) {
+ $retryAfter = $limit->getRetryAfter();
+ $this->logger->warning('Signal submission rejected due to rate limiting.', [
+ 'client_key' => $clientKey,
+ 'retry_after' => $retryAfter?->format(DATE_ATOM),
+ ]);
+ throw new SubmissionRateLimitedException($limit->getRetryAfter());
+ }
+
+ $this->logger->debug('Signal submission passed rate limiting.', [
+ 'client_key' => $clientKey,
+ 'remaining_tokens' => $limit->getRemainingTokens(),
+ ]);
+ }
+
+}
diff --git a/server/src/ValueObject/Point.php b/server/src/ValueObject/Point.php
new file mode 100644
index 0000000..cd8802d
--- /dev/null
+++ b/server/src/ValueObject/Point.php
@@ -0,0 +1,66 @@
+assertValid($lat, $lng);
+
+ $this->lat = $lat;
+ $this->lng = $lng;
+ }
+
+ public static function fromArray(array $coordinates): self
+ {
+ if (! isset($coordinates['lat'], $coordinates['lng'])) {
+ throw InvalidPointException::missingCoordinates();
+ }
+
+ return new self((float) $coordinates['lat'], (float) $coordinates['lng']);
+ }
+
+ public static function fromLatLng(float $lat, float $lng): self
+ {
+ return new self($lat, $lng);
+ }
+
+ public function getLat(): float
+ {
+ return $this->lat;
+ }
+
+ public function getLng(): float
+ {
+ return $this->lng;
+ }
+
+ private function assertValid(float $lat, float $lng): void
+ {
+ if (! is_finite($lat) || ! is_finite($lng)) {
+ throw InvalidPointException::invalidNumber();
+ }
+
+ if ($lat < -90 || $lat > 90) {
+ throw InvalidPointException::latitudeOutOfRange($lat);
+ }
+
+ if ($lng < -180 || $lng > 180) {
+ throw InvalidPointException::longitudeOutOfRange($lng);
+ }
+ }
+}
diff --git a/server/symfony.lock b/server/symfony.lock
index 52b09e0..413f423 100644
--- a/server/symfony.lock
+++ b/server/symfony.lock
@@ -86,6 +86,15 @@
"bin/phpunit"
]
},
+ "sentry/sentry-symfony": {
+ "version": "5.6",
+ "recipe": {
+ "repo": "github.com/symfony/recipes-contrib",
+ "branch": "main",
+ "version": "5.0",
+ "ref": "b6cb4b34429dadecd7187852123be19d628fa37a"
+ }
+ },
"symfony/console": {
"version": "7.3",
"recipe": {
@@ -131,6 +140,42 @@
".editorconfig"
]
},
+ "symfony/mercure-bundle": {
+ "version": "0.3",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "0.3",
+ "ref": "528285147494380298f8f991ee8c47abebaf79db"
+ },
+ "files": [
+ "config/packages/mercure.yaml"
+ ]
+ },
+ "symfony/messenger": {
+ "version": "7.3",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "6.0",
+ "ref": "ba1ac4e919baba5644d31b57a3284d6ba12d52ee"
+ },
+ "files": [
+ "config/packages/messenger.yaml"
+ ]
+ },
+ "symfony/monolog-bundle": {
+ "version": "3.10",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "3.7",
+ "ref": "aff23899c4440dd995907613c1dd709b6f59503f"
+ },
+ "files": [
+ "config/packages/monolog.yaml"
+ ]
+ },
"symfony/property-info": {
"version": "7.3",
"recipe": {
@@ -155,5 +200,17 @@
"config/packages/routing.yaml",
"config/routes.yaml"
]
+ },
+ "symfony/validator": {
+ "version": "7.3",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "7.0",
+ "ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
+ },
+ "files": [
+ "config/packages/validator.yaml"
+ ]
}
}
diff --git a/server/tests/Functional/SignalControllerTest.php b/server/tests/Functional/SignalControllerTest.php
index 37e7a97..b1299c7 100644
--- a/server/tests/Functional/SignalControllerTest.php
+++ b/server/tests/Functional/SignalControllerTest.php
@@ -48,7 +48,9 @@ class SignalControllerTest extends WebTestCase
public function testIndexReturnsRecentSignals(): void
{
- $this->client->request('GET', '/api/signals');
+ $this->client->request('GET', '/api/signals', server: [
+ 'HTTP_ACCEPT' => 'application/json',
+ ]);
self::assertResponseIsSuccessful();
@@ -60,12 +62,14 @@ class SignalControllerTest extends WebTestCase
self::assertCount(3, $payload['points']);
self::assertSame(3, $payload['totals']['points']);
self::assertSame(2, $payload['totals']['contributors']);
- self::assertSame(-11.6852, $payload['points'][0]['lat']);
+ self::assertSame(-11.6852, $payload['points'][0]['signalLocation']['lat']);
}
public function testIndexRespectsLimitQueryParameter(): void
{
- $this->client->request('GET', '/api/signals?limit=1');
+ $this->client->request('GET', '/api/signals?limit=1', server: [
+ 'HTTP_ACCEPT' => 'application/json',
+ ]);
self::assertResponseIsSuccessful();
@@ -80,13 +84,20 @@ class SignalControllerTest extends WebTestCase
public function testStorePersistsNewSignal(): void
{
$body = [
- 'lat' => -11.6901,
- 'lng' => 27.4959,
+ 'signalLocation' => [
+ 'lat' => -11.6901,
+ 'lng' => 27.4959,
+ ],
+ 'userLocation' => [
+ 'lat' => -11.6899,
+ 'lng' => 27.4962,
+ ],
];
- $this->client->request('POST', '/api/signals', [], [], [
+ $this->client->request('POST', '/api/signals', server: [
'CONTENT_TYPE' => 'application/json',
- ], json_encode($body, JSON_THROW_ON_ERROR));
+ 'HTTP_ACCEPT' => 'application/json',
+ ], content: json_encode($body, JSON_THROW_ON_ERROR));
self::assertResponseStatusCodeSame(Response::HTTP_CREATED);
@@ -94,8 +105,10 @@ class SignalControllerTest extends WebTestCase
self::assertIsString($content);
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
self::assertSame('stored', $payload['status']);
- self::assertSame($body['lat'], $payload['point']['lat']);
- self::assertSame($body['lng'], $payload['point']['lng']);
+ self::assertSame($body['signalLocation']['lat'], $payload['point']['signalLocation']['lat']);
+ self::assertSame($body['signalLocation']['lng'], $payload['point']['signalLocation']['lng']);
+ self::assertSame($body['userLocation']['lat'], $payload['point']['userLocation']['lat']);
+ self::assertSame($body['userLocation']['lng'], $payload['point']['userLocation']['lng']);
$repository = $this->entityManager->getRepository(Signal::class);
$signals = $repository->findAll();
@@ -105,19 +118,58 @@ class SignalControllerTest extends WebTestCase
public function testStoreRejectsInvalidCoordinates(): void
{
$body = [
- 'lat' => 181,
- 'lng' => 10,
+ 'signalLocation' => [
+ 'lat' => 181,
+ 'lng' => 10,
+ ],
+ 'userLocation' => [
+ 'lat' => 10,
+ 'lng' => 10,
+ ],
];
- $this->client->request('POST', '/api/signals', [], [], [
+ $this->client->request('POST', '/api/signals', server: [
'CONTENT_TYPE' => 'application/json',
- ], json_encode($body, JSON_THROW_ON_ERROR));
+ 'HTTP_ACCEPT' => 'application/json',
+ ], content: json_encode($body, JSON_THROW_ON_ERROR));
self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
- $content = $this->client->getResponse()->getContent();
+ $response = $this->client->getResponse();
+ self::assertSame('invalid_coordinates', $response->headers->get('x-error-code'));
+
+ $content = $response->getContent();
self::assertIsString($content);
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
- self::assertSame('out_of_bounds', $payload['error']);
+ self::assertSame('Latitude 181.000000 is out of bounds.', $payload['detail'] ?? null);
+ }
+
+ public function testStoreRejectsPointTooFar(): void
+ {
+ $body = [
+ 'signalLocation' => [
+ 'lat' => -11.6901,
+ 'lng' => 27.4959,
+ ],
+ 'userLocation' => [
+ 'lat' => -11.7005,
+ 'lng' => 27.4804,
+ ],
+ ];
+
+ $this->client->request('POST', '/api/signals', server: [
+ 'CONTENT_TYPE' => 'application/json',
+ 'HTTP_ACCEPT' => 'application/json',
+ ], content: json_encode($body, JSON_THROW_ON_ERROR));
+
+ self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
+
+ $response = $this->client->getResponse();
+ self::assertSame('point_too_far', $response->headers->get('x-error-code'));
+
+ $content = $response->getContent();
+ self::assertIsString($content);
+ $payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ self::assertSame('The submitted point must be within 1.00km of your location.', $payload['detail'] ?? null);
}
}