From 65cd112170c10e8fc612838dd14f7b7f0f68cac8 Mon Sep 17 00:00:00 2001 From: jona159 Date: Fri, 19 Jun 2026 07:26:50 +0200 Subject: [PATCH 01/19] feat: wip --- .../0044_add_device_location_privacy.sql | 6 + app/db/models/device.server.ts | 36 +++- app/db/schema/device.ts | 8 + app/lib/api-schemas/devices.ts | 16 ++ app/lib/device-transform.ts | 27 ++- app/lib/geomasking.server.ts | 117 +++++++++++ app/lib/location.ts | 58 +++++- app/lib/openapi/schemas/device.ts | 58 ++++-- app/routes/api.boxes.$deviceId.ts | 32 ++- app/routes/api.boxes.ts | 24 ++- app/routes/api.users.me.boxes.$deviceId.ts | 4 +- app/routes/api.users.me.boxes.ts | 2 +- app/routes/device.$deviceId.edit.location.tsx | 183 ++++++++++++++++-- 13 files changed, 532 insertions(+), 39 deletions(-) create mode 100644 app/db/drizzle/0044_add_device_location_privacy.sql create mode 100644 app/lib/geomasking.server.ts diff --git a/app/db/drizzle/0044_add_device_location_privacy.sql b/app/db/drizzle/0044_add_device_location_privacy.sql new file mode 100644 index 00000000..17adfa16 --- /dev/null +++ b/app/db/drizzle/0044_add_device_location_privacy.sql @@ -0,0 +1,6 @@ +ALTER TABLE "device" ADD COLUMN "location_privacy" text DEFAULT 'exact' NOT NULL;--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "location_privacy_radius_meters" integer DEFAULT 500 NOT NULL;--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "location_privacy_method" text DEFAULT 'deterministic-jitter-v1' NOT NULL;--> statement-breakpoint +ALTER TABLE "device" ADD CONSTRAINT "device_location_privacy_check" CHECK ("location_privacy" IN ('exact', 'masked'));--> statement-breakpoint +ALTER TABLE "device" ADD CONSTRAINT "device_location_privacy_radius_check" CHECK ("location_privacy_radius_meters" IN (250, 500, 1000, 5000));--> statement-breakpoint +ALTER TABLE "device" ADD CONSTRAINT "device_location_privacy_method_check" CHECK ("location_privacy_method" = 'deterministic-jitter-v1'); diff --git a/app/db/models/device.server.ts b/app/db/models/device.server.ts index 3051596c..8d970180 100644 --- a/app/db/models/device.server.ts +++ b/app/db/models/device.server.ts @@ -51,6 +51,9 @@ const BASE_DEVICE_COLUMNS = { model: true, latitude: true, longitude: true, + locationPrivacy: true, + locationPrivacyRadiusMeters: true, + locationPrivacyMethod: true, status: true, createdAt: true, updatedAt: true, @@ -161,6 +164,9 @@ export function getUserDevice({ id, userId }: Pick) { updatedAt: true, latitude: true, longitude: true, + locationPrivacy: true, + locationPrivacyRadiusMeters: true, + locationPrivacyMethod: true, userId: true, }, }) @@ -201,6 +207,9 @@ export function getDeviceWithoutSensors({ id }: Pick) { updatedAt: true, latitude: true, longitude: true, + locationPrivacy: true, + locationPrivacyRadiusMeters: true, + locationPrivacyMethod: true, userId: true, useAuth: true, model: true, @@ -217,7 +226,10 @@ export async function updateDeviceLocation({ id, latitude, longitude, -}: Pick) { + locationPrivacy, + locationPrivacyRadiusMeters, +}: Pick & + Partial>) { const [existingDevice] = await drizzleClient .select() .from(device) @@ -232,7 +244,15 @@ export async function updateDeviceLocation({ return drizzleClient .update(device) - .set({ latitude, longitude, updatedAt: sql`NOW()` }) + .set({ + latitude, + longitude, + ...(locationPrivacy !== undefined && { locationPrivacy }), + ...(locationPrivacyRadiusMeters !== undefined && { + locationPrivacyRadiusMeters, + }), + updatedAt: sql`NOW()`, + }) .where(eq(device.id, id)) } @@ -247,6 +267,8 @@ export type UpdateDeviceArgs = { model?: string useAuth?: boolean location?: { lat: number; lng: number; height?: number } + locationPrivacy?: string + locationPrivacyRadiusMeters?: number sensors?: SensorUpdateArgs[] } @@ -288,6 +310,8 @@ export async function updateDevice( 'model', 'useAuth', 'link', + 'locationPrivacy', + 'locationPrivacyRadiusMeters', ] for (const field of updatableFields) { @@ -773,6 +797,9 @@ const MINIMAL_COLUMNS = { exposure: true, longitude: true, latitude: true, + locationPrivacy: true, + locationPrivacyRadiusMeters: true, + locationPrivacyMethod: true, } const DEFAULT_COLUMNS = { @@ -788,6 +815,9 @@ const DEFAULT_COLUMNS = { updatedAt: true, longitude: true, latitude: true, + locationPrivacy: true, + locationPrivacyRadiusMeters: true, + locationPrivacyMethod: true, } export async function findDevices( @@ -885,6 +915,8 @@ export async function createDevice(deviceData: any, userId: string) { : null, latitude: deviceData.latitude, longitude: deviceData.longitude, + locationPrivacy: deviceData.locationPrivacy, + locationPrivacyRadiusMeters: deviceData.locationPrivacyRadiusMeters, }) .returning() diff --git a/app/db/schema/device.ts b/app/db/schema/device.ts index 022f7708..2d98a748 100644 --- a/app/db/schema/device.ts +++ b/app/db/schema/device.ts @@ -15,6 +15,7 @@ import { unique, date, bigint, + integer, } from 'drizzle-orm/pg-core' import { DeviceExposureEnum, DeviceModelEnum, DeviceStatusEnum } from './enum' import { location } from './location' @@ -51,6 +52,13 @@ export const device = pgTable('device', { expiresAt: date('expires_at', { mode: 'date' }), latitude: doublePrecision('latitude').notNull(), longitude: doublePrecision('longitude').notNull(), + locationPrivacy: text('location_privacy').default('exact').notNull(), + locationPrivacyRadiusMeters: integer('location_privacy_radius_meters') + .default(500) + .notNull(), + locationPrivacyMethod: text('location_privacy_method') + .default('deterministic-jitter-v1') + .notNull(), sensorWikiModel: text('sensor_wiki_model'), userId: text('user_id') .notNull() diff --git a/app/lib/api-schemas/devices.ts b/app/lib/api-schemas/devices.ts index 66dc3fb2..d84baf40 100644 --- a/app/lib/api-schemas/devices.ts +++ b/app/lib/api-schemas/devices.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + LOCATION_PRIVACY_RADIUS_VALUES, + LOCATION_PRIVACY_VALUES, +} from '~/lib/location' export const CreateDeviceSchema = z.object({ // public API request shape @@ -25,6 +29,18 @@ export const CreateDeviceSchema = z.object({ if (Array.isArray(loc)) return loc return [loc.lng, loc.lat, ...(loc.height ? [loc.height] : [])] }), + locationPrivacy: z.enum(LOCATION_PRIVACY_VALUES).optional().default('exact'), + locationPrivacyRadiusMeters: z + .number() + .refine( + (value): value is (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number] => + LOCATION_PRIVACY_RADIUS_VALUES.includes( + value as (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number], + ), + 'Location privacy radius is invalid', + ) + .optional() + .default(500), grouptag: z.array(z.string()).optional().default([]), model: z .enum([ diff --git a/app/lib/device-transform.ts b/app/lib/device-transform.ts index f67bba1f..8e58a041 100644 --- a/app/lib/device-transform.ts +++ b/app/lib/device-transform.ts @@ -1,8 +1,12 @@ import { type Device, type Sensor } from '~/db/schema' +import { + getPublicLocation, + type LocationDisclosure, +} from '~/lib/geomasking.server' import { toIsoString } from '~/utils' export type DeviceWithSensors = Device & { - sensors: Sensor[] + sensors?: Sensor[] } export type TransformedDevice = { @@ -17,6 +21,10 @@ export type TransformedDevice = { model: string | null latitude: number longitude: number + locationPrivacy?: string | null + locationPrivacyRadiusMeters?: number | null + locationPrivacyMethod?: string | null + locationDisclosure: LocationDisclosure useAuth: boolean | null access_token: string | null public: boolean | null @@ -66,15 +74,30 @@ export type TransformedDevice = { */ export function transformDeviceToApiFormat( box: DeviceWithSensors, + options: { includeExactLocation?: boolean } = {}, ): TransformedDevice { const { id, tags, sensors, apiKey, ...rest } = box const timestamp = box.updatedAt.toISOString() - const coordinates = [box.longitude, box.latitude] + const publicLocation = options.includeExactLocation + ? { + latitude: box.latitude, + longitude: box.longitude, + disclosure: { + mode: 'exact' as const, + accuracyMeters: 0 as const, + method: null, + }, + } + : getPublicLocation(box) + const coordinates = [publicLocation.longitude, publicLocation.latitude] return { _id: id, grouptag: tags || [], ...rest, + latitude: publicLocation.latitude, + longitude: publicLocation.longitude, + locationDisclosure: publicLocation.disclosure, createdAt: toIsoString(box.createdAt)!, updatedAt: toIsoString(box.updatedAt)!, expiresAt: toIsoString(box.expiresAt), diff --git a/app/lib/geomasking.server.ts b/app/lib/geomasking.server.ts new file mode 100644 index 00000000..98a8a905 --- /dev/null +++ b/app/lib/geomasking.server.ts @@ -0,0 +1,117 @@ +import { createHmac } from 'node:crypto' +import { LOCATION_PRIVACY_METHOD } from '~/lib/location' + +export type LocationDisclosure = + | { + mode: 'exact' + accuracyMeters: 0 + method: null + } + | { + mode: 'masked' + accuracyMeters: number + method: typeof LOCATION_PRIVACY_METHOD + } + +export type PublicLocation = { + latitude: number + longitude: number + disclosure: LocationDisclosure +} + +export type LocationPrivacyDevice = { + id: string + latitude: number + longitude: number + locationPrivacy?: string | null + locationPrivacyRadiusMeters?: number | null + locationPrivacyMethod?: string | null +} + +const EARTH_RADIUS_METERS = 6371008.8 + +function randomUnitValues(seed: string) { + const digest = createHmac( + 'sha256', + process.env.SESSION_SECRET || 'openSenseMap-location-privacy', + ) + .update(seed) + .digest() + + const first = digest.readUInt32BE(0) / 0xffffffff + const second = digest.readUInt32BE(4) / 0xffffffff + + return [first, second] as const +} + +function destinationPoint( + latitude: number, + longitude: number, + distanceMeters: number, + bearingRadians: number, +) { + const angularDistance = distanceMeters / EARTH_RADIUS_METERS + const latitudeRadians = (latitude * Math.PI) / 180 + const longitudeRadians = (longitude * Math.PI) / 180 + + const destinationLatitude = Math.asin( + Math.sin(latitudeRadians) * Math.cos(angularDistance) + + Math.cos(latitudeRadians) * + Math.sin(angularDistance) * + Math.cos(bearingRadians), + ) + + const destinationLongitude = + longitudeRadians + + Math.atan2( + Math.sin(bearingRadians) * + Math.sin(angularDistance) * + Math.cos(latitudeRadians), + Math.cos(angularDistance) - + Math.sin(latitudeRadians) * Math.sin(destinationLatitude), + ) + + return { + latitude: Number(((destinationLatitude * 180) / Math.PI).toFixed(6)), + longitude: Number( + ((((destinationLongitude * 180) / Math.PI + 540) % 360) - 180).toFixed(6), + ), + } +} + +export function getPublicLocation( + device: LocationPrivacyDevice, +): PublicLocation { + if (device.locationPrivacy !== 'masked') { + return { + latitude: device.latitude, + longitude: device.longitude, + disclosure: { + mode: 'exact', + accuracyMeters: 0, + method: null, + }, + } + } + + const radiusMeters = device.locationPrivacyRadiusMeters ?? 500 + const method = LOCATION_PRIVACY_METHOD + const [distanceUnit, bearingUnit] = randomUnitValues( + `${method}:${device.id}:${radiusMeters}`, + ) + const masked = destinationPoint( + device.latitude, + device.longitude, + radiusMeters * Math.sqrt(distanceUnit), + 2 * Math.PI * bearingUnit, + ) + + return { + ...masked, + disclosure: { + mode: 'masked', + accuracyMeters: radiusMeters, + method, + }, + } +} diff --git a/app/lib/location.ts b/app/lib/location.ts index 81460b67..82a26ad9 100644 --- a/app/lib/location.ts +++ b/app/lib/location.ts @@ -11,6 +11,10 @@ export const LOCATION_LIMITS = { }, } as const +export const LOCATION_PRIVACY_VALUES = ['exact', 'masked'] as const +export const LOCATION_PRIVACY_RADIUS_VALUES = [250, 500, 1000, 5000] as const +export const LOCATION_PRIVACY_METHOD = 'deterministic-jitter-v1' as const + const emptyStringToUndefined = (value: unknown) => { if (typeof value === 'string' && value.trim() === '') { return undefined @@ -59,7 +63,22 @@ export const locationSchema = z.object({ ), }) +export const locationPrivacySchema = z.object({ + locationPrivacy: z.enum(LOCATION_PRIVACY_VALUES).default('exact'), + locationPrivacyRadiusMeters: z.coerce + .number() + .refine( + (value): value is (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number] => + LOCATION_PRIVACY_RADIUS_VALUES.includes( + value as (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number], + ), + 'Location privacy radius is invalid', + ) + .default(500), +}) + export type LocationData = z.infer +export type LocationPrivacyData = z.infer export function validLngLat(lng: number, lat: number): boolean { return locationSchema.safeParse({ @@ -114,9 +133,13 @@ export function getLocationFieldErrors(error: z.ZodError) { export type LocationFieldErrors = { latitude?: string longitude?: string + locationPrivacy?: string + locationPrivacyRadiusMeters?: string } -export function validateLocationFieldErrors(value: unknown): LocationFieldErrors { +export function validateLocationFieldErrors( + value: unknown, +): LocationFieldErrors { const parsed = locationSchema.safeParse(value) if (parsed.success) { @@ -125,3 +148,36 @@ export function validateLocationFieldErrors(value: unknown): LocationFieldErrors return getLocationFieldErrors(parsed.error) } + +export function parseLocationPrivacyFormData(formData: FormData): + | { + success: true + data: LocationPrivacyData + } + | { + success: false + errors: LocationFieldErrors + } { + const parsed = locationPrivacySchema.safeParse({ + locationPrivacy: formData.get('locationPrivacy'), + locationPrivacyRadiusMeters: formData.get('locationPrivacyRadiusMeters'), + }) + + if (parsed.success) { + return { + success: true, + data: parsed.data, + } + } + + const flattened = z.flattenError(parsed.error) + + return { + success: false, + errors: { + locationPrivacy: flattened.fieldErrors.locationPrivacy?.[0], + locationPrivacyRadiusMeters: + flattened.fieldErrors.locationPrivacyRadiusMeters?.[0], + }, + } +} diff --git a/app/lib/openapi/schemas/device.ts b/app/lib/openapi/schemas/device.ts index 8f0c6529..1f664668 100644 --- a/app/lib/openapi/schemas/device.ts +++ b/app/lib/openapi/schemas/device.ts @@ -51,18 +51,27 @@ export const DeviceSensorUpdateSchema = z 'Existing sensor id. `_id` is used by the legacy API and is preferred for backwards compatibility.', example: '60a13611a877b3001b8ffd59', }), - new: z.union([z.literal(true), z.literal('true')]).optional().meta({ - description: 'Whether this sensor should be created as new.', - example: true, - }), - edited: z.union([z.literal(true), z.literal('true')]).optional().meta({ - description: 'Whether this sensor should be created or updated.', - example: true, - }), - deleted: z.union([z.literal(true), z.literal('true')]).optional().meta({ - description: 'Whether this sensor should be deleted.', - example: true, - }), + new: z + .union([z.literal(true), z.literal('true')]) + .optional() + .meta({ + description: 'Whether this sensor should be created as new.', + example: true, + }), + edited: z + .union([z.literal(true), z.literal('true')]) + .optional() + .meta({ + description: 'Whether this sensor should be created or updated.', + example: true, + }), + deleted: z + .union([z.literal(true), z.literal('true')]) + .optional() + .meta({ + description: 'Whether this sensor should be deleted.', + example: true, + }), title: z.string().optional().meta({ example: 'PM10', }), @@ -79,7 +88,7 @@ export const DeviceSensorUpdateSchema = z }) .transform(({ id, ...sensor }) => ({ ...sensor, - ...(sensor._id ?? id ? { _id: sensor._id ?? id } : {}), + ...((sensor._id ?? id) ? { _id: sensor._id ?? id } : {}), })) .meta({ id: 'DeviceSensorUpdate', @@ -150,6 +159,29 @@ export const ApiDeviceSchema = z description: 'Device longitude', example: 13.404954, }), + locationPrivacy: z.string().optional().meta({ + description: 'Stored public location privacy preference.', + example: 'masked', + }), + locationPrivacyRadiusMeters: z.number().optional().meta({ + description: 'Configured masking radius in meters.', + example: 500, + }), + locationPrivacyMethod: z.string().optional().meta({ + description: 'Configured location masking method version.', + example: 'deterministic-jitter-v1', + }), + locationDisclosure: z + .object({ + mode: z.enum(['exact', 'masked']), + accuracyMeters: z.number(), + method: z.string().nullable(), + }) + .optional() + .meta({ + description: + 'Describes whether returned coordinates are exact or geomasked.', + }), useAuth: z.boolean().optional().meta({ description: 'Whether the device requires authentication', example: true, diff --git a/app/routes/api.boxes.$deviceId.ts b/app/routes/api.boxes.$deviceId.ts index bf4f2fa4..7bcf05e2 100644 --- a/app/routes/api.boxes.$deviceId.ts +++ b/app/routes/api.boxes.$deviceId.ts @@ -38,6 +38,10 @@ import { } from '~/middleware/content-type-header.server' import { parseJsonBody } from '~/lib/request-parsing' import { LocationObjectSchema } from '~/lib/openapi/schemas/location' +import { + LOCATION_PRIVACY_RADIUS_VALUES, + LOCATION_PRIVACY_VALUES, +} from '~/lib/location' const messages = { conflictingSensorsAndAddons: @@ -86,6 +90,28 @@ const UpdateDeviceRequestSchema = z location: LocationObjectSchema.optional(), + locationPrivacy: z.enum(LOCATION_PRIVACY_VALUES).optional().meta({ + description: + 'Whether public responses expose the exact or masked location.', + example: 'masked', + }), + + locationPrivacyRadiusMeters: z + .number() + .refine( + (value): value is (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number] => + LOCATION_PRIVACY_RADIUS_VALUES.includes( + value as (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number], + ), + 'Location privacy radius is invalid', + ) + .optional() + .meta({ + description: + 'Radius in meters used when `locationPrivacy` is `masked`.', + example: 500, + }), + grouptag: z .union([z.string(), z.array(z.string())]) .transform((value) => (Array.isArray(value) ? value : [value])) @@ -452,6 +478,8 @@ async function put(request: Request, user: User, deviceId: string) { useAuth: body.useAuth, link: body.weblink, location: locationData, + locationPrivacy: body.locationPrivacy, + locationPrivacyRadiusMeters: body.locationPrivacyRadiusMeters, grouptag: body.grouptag, sensors: body.sensors, } @@ -473,7 +501,9 @@ async function put(request: Request, user: User, deviceId: string) { return StandardResponse.internalServerError() } - const apiResponse = transformDeviceToApiFormat(deviceWithSensors) + const apiResponse = transformDeviceToApiFormat(deviceWithSensors, { + includeExactLocation: true, + }) const responseParsed = await ApiDeviceSchema.safeParseAsync(apiResponse) diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts index 463c8130..d8b86fbd 100644 --- a/app/routes/api.boxes.ts +++ b/app/routes/api.boxes.ts @@ -4,7 +4,7 @@ import { findDevices, type FindDevicesOptions, } from '~/db/models/device.server' -import { type Device, type User } from '~/db/schema' +import { type User } from '~/db/schema' import { transformDeviceToApiFormat } from '~/lib/device-transform' import { StandardResponse } from '~/lib/responses' @@ -170,9 +170,12 @@ export async function loader({ request }: Route.LoaderArgs) { const devices = await findDevices(params) if (params.format === 'geojson') { + const transformedDevices = devices.map((device) => + transformDeviceToApiFormat(device), + ) const geojson = { type: 'FeatureCollection', - features: devices.map((device: Device) => ({ + features: transformedDevices.map((device) => ({ type: 'Feature', geometry: { type: 'Point', @@ -190,11 +193,14 @@ export async function loader({ request }: Route.LoaderArgs) { }, }) } - return Response.json(devices, { - headers: { - 'Content-Type': 'application/json; charset=utf-8', + return Response.json( + devices.map((device) => transformDeviceToApiFormat(device)), + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, }, - }) + ) } export const action = async ({ request }: Route.ActionArgs) => { @@ -240,6 +246,8 @@ async function post(request: Request, user: User) { model: sensorsProvided ? undefined : validatedData.model, latitude: latitude, longitude: longitude, + locationPrivacy: validatedData.locationPrivacy, + locationPrivacyRadiusMeters: validatedData.locationPrivacyRadiusMeters, tags: validatedData.grouptag, sensors: sensorsProvided ? validatedData.sensors.map((s) => ({ @@ -253,7 +261,9 @@ async function post(request: Request, user: User) { ) // Build response object using helper function - const responseData = transformDeviceToApiFormat(newDevice) + const responseData = transformDeviceToApiFormat(newDevice, { + includeExactLocation: true, + }) return StandardResponse.created(responseData) } catch { diff --git a/app/routes/api.users.me.boxes.$deviceId.ts b/app/routes/api.users.me.boxes.$deviceId.ts index 077faada..9f5f5fb4 100644 --- a/app/routes/api.users.me.boxes.$deviceId.ts +++ b/app/routes/api.users.me.boxes.$deviceId.ts @@ -107,7 +107,9 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => { await GetCurrentUserDeviceResponseSchema.safeParseAsync({ code: 'Ok', data: { - device: transformDeviceToApiFormat(device), + device: transformDeviceToApiFormat(device, { + includeExactLocation: true, + }), }, }) diff --git a/app/routes/api.users.me.boxes.ts b/app/routes/api.users.me.boxes.ts index 578acba9..84e002d1 100644 --- a/app/routes/api.users.me.boxes.ts +++ b/app/routes/api.users.me.boxes.ts @@ -86,7 +86,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => { const userBoxes = await getUserDevices(user.id) const transformedBoxes = userBoxes.map((box) => - transformDeviceToApiFormat(box), + transformDeviceToApiFormat(box, { includeExactLocation: true }), ) const boxesWithIntegrations = diff --git a/app/routes/device.$deviceId.edit.location.tsx b/app/routes/device.$deviceId.edit.location.tsx index 7b657520..18665cd8 100644 --- a/app/routes/device.$deviceId.edit.location.tsx +++ b/app/routes/device.$deviceId.edit.location.tsx @@ -17,11 +17,14 @@ import { getUserId } from '~/services/session-service.server' import { BaseMap } from '~/components/base-map' import { LOCATION_LIMITS, + LOCATION_PRIVACY_RADIUS_VALUES, isValidLocation, parseLocationFormData, + parseLocationPrivacyFormData, validateLocationFieldErrors, type LocationData, type LocationFieldErrors, + type LocationPrivacyData, } from '~/lib/location' import { useTranslation } from 'react-i18next' import { @@ -46,7 +49,7 @@ function normalizeCoordinate(value: number | null) { return Number(value.toFixed(6)) } -function normalizeLocationValues(values: LocationAutosaveValues) { +function normalizeLocationValues(values: MarkerValue) { return { latitude: normalizeCoordinate(values.latitude), longitude: normalizeCoordinate(values.longitude), @@ -62,6 +65,7 @@ export type LocationActionData = | { ok: true location: LocationData + locationPrivacy: LocationPrivacyData errors: null savedAt: string } @@ -73,6 +77,8 @@ export type LocationActionData = type LocationAutosaveValues = { latitude: number | null longitude: number | null + locationPrivacy: LocationPrivacyData['locationPrivacy'] + locationPrivacyRadiusMeters: LocationPrivacyData['locationPrivacyRadiusMeters'] } //***************************************************** @@ -117,12 +123,16 @@ export async function action({ request, params }: Route.ActionArgs) { const formData = await request.formData() const parsed = parseLocationFormData(formData) + const privacyParsed = parseLocationPrivacyFormData(formData) - if (!parsed.success) { + if (!parsed.success || !privacyParsed.success) { return data( { ok: false as const, - errors: parsed.errors, + errors: { + ...(parsed.success ? {} : parsed.errors), + ...(privacyParsed.success ? {} : privacyParsed.errors), + }, }, { status: 400 }, ) @@ -132,11 +142,14 @@ export async function action({ request, params }: Route.ActionArgs) { id, latitude: parsed.data.latitude, longitude: parsed.data.longitude, + locationPrivacy: privacyParsed.data.locationPrivacy, + locationPrivacyRadiusMeters: privacyParsed.data.locationPrivacyRadiusMeters, }) return data({ ok: true as const, location: parsed.data, + locationPrivacy: privacyParsed.data, errors: null, savedAt: new Date().toISOString(), }) @@ -154,8 +167,28 @@ export default function EditLocation() { }), [device.latitude, device.longitude], ) + const initialLocationPrivacy = useMemo( + () => ({ + locationPrivacy: device.locationPrivacy === 'masked' ? 'masked' : 'exact', + locationPrivacyRadiusMeters: + device.locationPrivacyRadiusMeters && + LOCATION_PRIVACY_RADIUS_VALUES.includes( + device.locationPrivacyRadiusMeters as (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number], + ) + ? (device.locationPrivacyRadiusMeters as LocationPrivacyData['locationPrivacyRadiusMeters']) + : 500, + }), + [device.locationPrivacy, device.locationPrivacyRadiusMeters], + ) const [marker, setMarker] = useState(initialLocation) + const [locationPrivacy, setLocationPrivacy] = useState< + LocationPrivacyData['locationPrivacy'] + >(initialLocationPrivacy.locationPrivacy) + const [locationPrivacyRadiusMeters, setLocationPrivacyRadiusMeters] = + useState( + initialLocationPrivacy.locationPrivacyRadiusMeters, + ) const currentLocation = useMemo(() => { const candidate = { @@ -181,6 +214,8 @@ export default function EditLocation() { return { latitude: String(values.latitude), longitude: String(values.longitude), + locationPrivacy: values.locationPrivacy, + locationPrivacyRadiusMeters: String(values.locationPrivacyRadiusMeters), } }, []) @@ -195,23 +230,41 @@ export default function EditLocation() { ): LocationAutosaveValues => { if (!actionData.ok) return submittedValues - return normalizeLocationValues(submittedValues) + return { + ...normalizeLocationValues(submittedValues), + locationPrivacy: actionData.locationPrivacy.locationPrivacy, + locationPrivacyRadiusMeters: + actionData.locationPrivacy.locationPrivacyRadiusMeters, + } }, [], ) const autosaveValues = useMemo( () => - normalizeLocationValues({ - latitude: marker.latitude, - longitude: marker.longitude, - }), - [marker.latitude, marker.longitude], + ({ + ...normalizeLocationValues({ + latitude: marker.latitude, + longitude: marker.longitude, + }), + locationPrivacy, + locationPrivacyRadiusMeters, + }) as LocationAutosaveValues, + [ + marker.latitude, + marker.longitude, + locationPrivacy, + locationPrivacyRadiusMeters, + ], ) const initialAutosaveValues = useMemo( - () => normalizeLocationValues(initialLocation), - [initialLocation], + () => + ({ + ...normalizeLocationValues(initialLocation), + ...initialLocationPrivacy, + }) as LocationAutosaveValues, + [initialLocation, initialLocationPrivacy], ) const autosave = useAutosaveFetcher< @@ -237,6 +290,8 @@ export default function EditLocation() { const locationErrors = { latitude: clientErrors.latitude ?? serverErrors.latitude, longitude: clientErrors.longitude ?? serverErrors.longitude, + locationPrivacy: serverErrors.locationPrivacy, + locationPrivacyRadiusMeters: serverErrors.locationPrivacyRadiusMeters, } const hasClientErrors = Boolean( @@ -275,6 +330,28 @@ export default function EditLocation() { })) } + const onLocationPrivacyChange = ( + event: React.ChangeEvent, + ) => { + setLocationPrivacy(event.target.value === 'masked' ? 'masked' : 'exact') + } + + const onLocationPrivacyRadiusChange = ( + event: React.ChangeEvent, + ) => { + const radius = Number(event.target.value) + + if ( + LOCATION_PRIVACY_RADIUS_VALUES.includes( + radius as (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number], + ) + ) { + setLocationPrivacyRadiusMeters( + radius as LocationPrivacyData['locationPrivacyRadiusMeters'], + ) + } + } + const resetToOriginalLocation = () => { setMarker({ ...originalLocation }) } @@ -410,6 +487,90 @@ export default function EditLocation() { +
+
+ + Public location + + +
+ + + +
+
+ +
+ + + + + {locationErrors.locationPrivacy || + locationErrors.locationPrivacyRadiusMeters ? ( +

+ {locationErrors.locationPrivacy ?? + locationErrors.locationPrivacyRadiusMeters} +

+ ) : null} +
+
+