From 98d8c0d644fd457e8a3b6a7498638246e664ac78 Mon Sep 17 00:00:00 2001 From: JerryVincent Date: Wed, 17 Dec 2025 16:50:06 +0100 Subject: [PATCH 1/4] This commit will enable the users to edit their device details.. --- app/components/mydevices/dt/columns.tsx | 240 ++-- app/routes/device.$deviceId.edit.general.tsx | 532 ++++----- app/routes/device.$deviceId.edit.sensors.tsx | 1039 +++++++++--------- 3 files changed, 928 insertions(+), 883 deletions(-) diff --git a/app/components/mydevices/dt/columns.tsx b/app/components/mydevices/dt/columns.tsx index b61314e4..894baaca 100644 --- a/app/components/mydevices/dt/columns.tsx +++ b/app/components/mydevices/dt/columns.tsx @@ -1,59 +1,59 @@ -"use client"; +'use client' -import { type ColumnDef } from "@tanstack/react-table"; -import { ArrowUpDown, ClipboardCopy, Ellipsis } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { type ColumnDef } from '@tanstack/react-table' +import { ArrowUpDown, ClipboardCopy, Ellipsis } from 'lucide-react' +import { Button } from '@/components/ui/button' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu"; -import { type Device } from "~/schema"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '~/components/ui/dropdown-menu' +import { type Device } from '~/schema' export type SenseBox = { - id: string; - name: string; - exposure: Device["exposure"]; - // model: string; -}; + id: string + name: string + exposure: Device['exposure'] + // model: string; +} -const colStyle = "pl-0 dark:text-white"; +const colStyle = 'pl-0 dark:text-white' export const columns: ColumnDef[] = [ - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ); - }, - }, - { - accessorKey: "exposure", - header: ({ column }) => { - return ( - - ); - }, - }, - /* { + { + accessorKey: 'name', + header: ({ column }) => { + return ( + + ) + }, + }, + { + accessorKey: 'exposure', + header: ({ column }) => { + return ( + + ) + }, + }, + /* { accessorKey: "model", header: ({ column }) => { return ( @@ -68,76 +68,76 @@ export const columns: ColumnDef[] = [ ); }, }, */ - { - accessorKey: "id", - header: () =>
Sensebox ID
, - cell: ({ row }) => { - const senseBox = row.original; + { + accessorKey: 'id', + header: () =>
Sensebox ID
, + cell: ({ row }) => { + const senseBox = row.original - return ( - //
-
- - {senseBox?.id} - - navigator.clipboard.writeText(senseBox?.id)} - className="ml-[6px] mr-1 inline-block h-4 w-4 align-text-bottom text-[#818a91] dark:text-white cursor-pointer" - /> -
- ); - }, - }, - { - id: "actions", - header: () =>
Actions
, - cell: ({ row }) => { - const senseBox = row.original; + return ( + //
+
+ + {senseBox?.id} + + navigator.clipboard.writeText(senseBox?.id)} + className="ml-[6px] mr-1 inline-block h-4 w-4 cursor-pointer align-text-bottom text-[#818a91] dark:text-white" + /> +
+ ) + }, + }, + { + id: 'actions', + header: () =>
Actions
, + cell: ({ row }) => { + const senseBox = row.original - return ( - - - - - - Actions - - - Overview - - - Show on map - - - Edit - - - Data upload - - - - Support - - - navigator.clipboard.writeText(senseBox?.id)} - className="cursor-pointer" - > - Copy ID - - - - ); - }, - }, -]; + return ( + + + + + + Actions + + + Overview + + + Show on map + + + Edit + + + Data upload + + + + Support + + + navigator.clipboard.writeText(senseBox?.id)} + className="cursor-pointer" + > + Copy ID + + + + ) + }, + }, +] diff --git a/app/routes/device.$deviceId.edit.general.tsx b/app/routes/device.$deviceId.edit.general.tsx index af37f226..a58e162e 100644 --- a/app/routes/device.$deviceId.edit.general.tsx +++ b/app/routes/device.$deviceId.edit.general.tsx @@ -1,300 +1,300 @@ -import { Save } from "lucide-react"; -import React, { useState } from "react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, - data, - redirect, - Form, - useActionData, - useLoaderData, - useOutletContext } from "react-router"; -import invariant from "tiny-invariant"; -import ErrorMessage from "~/components/error-message"; +import { Save } from 'lucide-react' +import React, { useState } from 'react' import { - deleteDevice, - getDeviceWithoutSensors, - updateDeviceInfo, -} from "~/models/device.server"; -import { verifyLogin } from "~/models/user.server"; -import { getUserEmail, getUserId } from "~/utils/session.server"; + type ActionFunctionArgs, + type LoaderFunctionArgs, + data, + redirect, + Form, + useActionData, + useLoaderData, + useOutletContext, +} from 'react-router' +import invariant from 'tiny-invariant' +import ErrorMessage from '~/components/error-message' +import { + deleteDevice, + getDeviceWithoutSensors, + updateDeviceInfo, +} from '~/models/device.server' +import { verifyLogin } from '~/models/user.server' +import { getUserEmail, getUserId } from '~/utils/session.server' //***************************************************** export async function loader({ request, params }: LoaderFunctionArgs) { - //* if user is not logged in, redirect to home - const userId = await getUserId(request); - if (!userId) return redirect("/"); + //* if user is not logged in, redirect to home + const userId = await getUserId(request) + if (!userId) return redirect('/') - const deviceID = params.deviceId; + const deviceID = params.deviceId - if (typeof deviceID !== "string") { - return redirect("/profile/me"); - } + if (typeof deviceID !== 'string') { + return redirect('/profile/me') + } - const deviceData = await getDeviceWithoutSensors({ id: deviceID }); + const deviceData = await getDeviceWithoutSensors({ id: deviceID }) - return { device: deviceData }; + return { device: deviceData } } //***************************************************** export async function action({ request, params }: ActionFunctionArgs) { - const formData = await request.formData(); - const { intent, name, exposure, passwordDelete } = - Object.fromEntries(formData); - - const errors = { - exposure: exposure ? null : "Invalid exposure.", - passwordDelete: passwordDelete ? null : "Password is required.", - }; + const formData = await request.formData() + const { intent, name, exposure, passwordDelete } = + Object.fromEntries(formData) + const errors = { + exposure: exposure ? null : 'Invalid exposure.', + passwordDelete: passwordDelete ? null : 'Password is required.', + } - const deviceID = params.deviceId; - invariant(typeof deviceID === "string", " Device id not found."); - invariant(typeof name === "string", "Device name is required."); - invariant(typeof exposure === "string", "Device name is required."); + const deviceID = params.deviceId + invariant(typeof deviceID === 'string', ' Device id not found.') + invariant(typeof name === 'string', 'Device name is required.') + invariant(typeof exposure === 'string', 'Device name is required.') - if ( - exposure !== "indoor" && - exposure !== "outdoor" && - exposure !== "mobile" && - exposure !== "unknown" - ) { - return data({ - errors: { - exposure: exposure ? null : "Invalid exposure.", - passwordDelete: errors.passwordDelete, - }, - status: 400, - }); - } + if ( + exposure !== 'indoor' && + exposure !== 'outdoor' && + exposure !== 'mobile' && + exposure !== 'unknown' + ) { + return data({ + errors: { + exposure: exposure ? null : 'Invalid exposure.', + passwordDelete: errors.passwordDelete, + }, + status: 400, + }) + } - switch (intent) { - case "save": { - await updateDeviceInfo({ id: deviceID, name: name, exposure: exposure }); - return data({ - errors: { - exposure: null, - passwordDelete: null, - }, - status: 200, - }); - } - case "delete": { - //* check password validaty - if (errors.passwordDelete) { - return data({ - errors, - status: 400, - }); - } - //* 1. get user email - const userEmail = await getUserEmail(request); - invariant(typeof userEmail === "string", "email not found"); - invariant( - typeof passwordDelete === "string", - "password must be a string", - ); - //* 2. check entered password - const user = await verifyLogin(userEmail, passwordDelete); - //* 3. retrun error if password is not correct - if (!user) { - return data( - { - errors: { - exposure: exposure ? null : "Invalid exposure.", - passwordDelete: "Invalid password", - }, - }, - { status: 400 }, - ); - } - //* 4. delete device - await deleteDevice({ id: deviceID }); + switch (intent) { + case 'save': { + await updateDeviceInfo({ id: deviceID, name: name, exposure: exposure }) + return data({ + errors: { + exposure: null, + passwordDelete: null, + }, + status: 200, + }) + } + case 'delete': { + //* check password validaty + if (errors.passwordDelete) { + return data({ + errors, + status: 400, + }) + } + //* 1. get user email + const userEmail = await getUserEmail(request) + invariant(typeof userEmail === 'string', 'email not found') + invariant(typeof passwordDelete === 'string', 'password must be a string') + //* 2. check entered password + const user = await verifyLogin(userEmail, passwordDelete) + //* 3. retrun error if password is not correct + if (!user) { + return data( + { + errors: { + exposure: exposure ? null : 'Invalid exposure.', + passwordDelete: 'Invalid password', + }, + }, + { status: 400 }, + ) + } + //* 4. delete device + await deleteDevice({ id: deviceID }) - return redirect("/profile/me"); - } - } + return redirect('/profile/me') + } + } - return redirect(""); + return redirect('') } //********************************** export default function () { - const { device } = useLoaderData(); - const actionData = useActionData(); - const [passwordDelVal, setPasswordVal] = useState(""); //* to enable delete account button - //* focus when an error occured - const nameRef = React.useRef(null); - const passwordDelRef = React.useRef(null); - const [name, setName] = useState(device?.name); - const [exposure, setExposure] = useState(device?.exposure); - //* to view toast on edit page - const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>(); - - React.useEffect(() => { - if (actionData) { - const hasErrors = Object.values(actionData?.errors).some( - (errorMessage) => errorMessage, - ); + const { device } = useLoaderData() + const actionData = useActionData() + const [passwordDelVal, setPasswordVal] = useState('') //* to enable delete account button + //* focus when an error occured + const nameRef = React.useRef(null) + const passwordDelRef = React.useRef(null) + const [name, setName] = useState(device?.name) + const [exposure, setExposure] = useState(device?.exposure) + //* to view toast on edit page + const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>() - //* when device data updated successfully - if (!hasErrors) { - setToastOpen(true); - // setToastOpenTest(true); - } - //* when password is null - else if (hasErrors && actionData?.errors?.passwordDelete) { - passwordDelRef.current?.focus(); - } - } - }, [actionData, setToastOpen]); + React.useEffect(() => { + if (actionData) { + const hasErrors = Object.values(actionData?.errors).some( + (errorMessage) => errorMessage, + ) - return ( -
- {/* general form */} -
-
- {/* Form */} -
- {/* Heading */} -
- {/* Title */} -
-
-

General

-
-
- -
-
-
+ //* when device data updated successfully + if (!hasErrors) { + setToastOpen(true) + // setToastOpenTest(true); + } + //* when password is null + else if (hasErrors && actionData?.errors?.passwordDelete) { + passwordDelRef.current?.focus() + } + } + }, [actionData, setToastOpen]) - {/* divider */} -
+ return ( +
+ {/* general form */} +
+
+ {/* Form */} + + {/* Heading */} +
+ {/* Title */} +
+
+

General

+
+
+ +
+
+
-
- {/* */} - {/* Name */} -
- + {/* divider */} +
-
- setName(e.target.value)} - ref={nameRef} - aria-describedby="name-error" - className="w-full rounded border border-gray-200 px-2 py-1 text-base" - /> -
-
+
+ {/* */} + {/* Name */} +
+ - {/* Exposure */} -
- +
+ setName(e.target.value)} + ref={nameRef} + aria-describedby="name-error" + className="w-full rounded border border-gray-200 px-2 py-1 text-base" + /> +
+
-
- -
-
+ {/* Exposure */} +
+ + {/* changed the case of the option values to lowercase as the + server expects lowercase */} +
+ +
+
- {/* Delete device */} -
-

- Delete senseBox -

-
+ {/* Delete device */} +
+

+ Delete senseBox +

+
-
-

- If you really want to delete your station, please type your - current password - all measurements will be deleted as well. -

-
-
- setPasswordVal(e.target.value)} - /> - {actionData?.errors?.passwordDelete && ( -
- {actionData.errors.passwordDelete} -
- )} -
- {/* Delete button */} -
- -
- {/* */} -
- -
-
-
- ); +
+

+ If you really want to delete your station, please type your + current password - all measurements will be deleted as well. +

+
+
+ setPasswordVal(e.target.value)} + /> + {actionData?.errors?.passwordDelete && ( +
+ {actionData.errors.passwordDelete} +
+ )} +
+ {/* Delete button */} +
+ +
+ {/* */} +
+ +
+
+
+ ) } export function ErrorBoundary() { - return ( -
- -
- ); + return ( +
+ +
+ ) } diff --git a/app/routes/device.$deviceId.edit.sensors.tsx b/app/routes/device.$deviceId.edit.sensors.tsx index b7f84414..acd530e4 100644 --- a/app/routes/device.$deviceId.edit.sensors.tsx +++ b/app/routes/device.$deviceId.edit.sensors.tsx @@ -1,517 +1,562 @@ import { - ChevronDownIcon, - Trash2, - ClipboardCopy, - Edit, - Plus, - Save, - Undo2, - X, -} from "lucide-react"; -import React, { useState } from "react"; -import { redirect , Form, useActionData, useLoaderData, useOutletContext, type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router"; -import invariant from "tiny-invariant"; + ChevronDownIcon, + Trash2, + ClipboardCopy, + Edit, + Plus, + Save, + Undo2, + X, +} from 'lucide-react' +import React, { useState, useCallback } from 'react' import { - DropdownMenu, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import ErrorMessage from "~/components/error-message"; + redirect, + Form, + useActionData, + useLoaderData, + useOutletContext, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from 'react-router' +import invariant from 'tiny-invariant' import { - addNewSensor, - deleteSensor, - getSensorsFromDevice, - updateSensor, -} from "~/models/sensor.server"; -import { assignIcon, getIcon, iconsList } from "~/utils/sensoricons"; -import { getUserId } from "~/utils/session.server"; + DropdownMenu, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import ErrorMessage from '~/components/error-message' +import { + addNewSensor, + deleteSensor, + getSensorsFromDevice, + updateSensor, +} from '~/models/sensor.server' +import { assignIcon, getIcon, iconsList } from '~/utils/sensoricons' +import { getUserId } from '~/utils/session.server' + +// Type for sensor data with editing state +interface SensorData { + id?: string + title?: string + unit?: string + sensorType?: string + icon?: string + editing?: boolean + edited?: boolean + new?: boolean + deleted?: boolean + deleting?: boolean + notValidInput?: boolean +} //***************************************************** export async function loader({ request, params }: LoaderFunctionArgs) { - //* if user is not logged in, redirect to home - const userId = await getUserId(request); - if (!userId) return redirect("/"); + const userId = await getUserId(request) + if (!userId) return redirect('/') - const deviceID = params.deviceId; - if (typeof deviceID !== "string") { - return "deviceID not found"; - } - const rawSensorsData = await getSensorsFromDevice(deviceID); + const deviceID = params.deviceId + if (typeof deviceID !== 'string') { + return 'deviceID not found' + } + const rawSensorsData = await getSensorsFromDevice(deviceID) - return rawSensorsData as any; + return rawSensorsData as SensorData[] } //***************************************************** export async function action({ request, params }: ActionFunctionArgs) { - //* ToDo: upadte it to include button clicks inside form - const formData = await request.formData(); - const { updatedSensorsData } = Object.fromEntries(formData); - - if (typeof updatedSensorsData !== "string") { - return { isUpdated: false }; - } - const updatedSensorsDataJson = JSON.parse(updatedSensorsData); - - for (const sensor of updatedSensorsDataJson) { - if (sensor?.new === true && sensor?.edited === true) { - const deviceID = params.deviceId; - invariant(deviceID, `deviceID not found!`); - - await addNewSensor({ - title: sensor.title, - unit: sensor.unit, - sensorType: sensor.sensorType, - deviceId: deviceID, - }); - } else if (sensor?.edited === true) { - await updateSensor({ - id: sensor.id, - title: sensor.title, - unit: sensor.unit, - sensorType: sensor.sensorType, - // icon: sensor.icon, - }); - } else if (sensor?.deleted === true) { - await deleteSensor(sensor.id); - } - } - - return { isUpdated: true }; + const formData = await request.formData() + const { updatedSensorsData } = Object.fromEntries(formData) + + if (typeof updatedSensorsData !== 'string') { + return { isUpdated: false } + } + const updatedSensorsDataJson = JSON.parse(updatedSensorsData) as SensorData[] + + for (const sensor of updatedSensorsDataJson) { + if (sensor?.new === true && sensor?.edited === true) { + const deviceID = params.deviceId + invariant(deviceID, `deviceID not found!`) + + await addNewSensor({ + title: sensor.title!, + unit: sensor.unit!, + sensorType: sensor.sensorType!, + deviceId: deviceID, + }) + } else if (sensor?.edited === true) { + await updateSensor({ + id: sensor.id!, + title: sensor.title!, + unit: sensor.unit!, + sensorType: sensor.sensorType!, + }) + } else if (sensor?.deleted === true) { + await deleteSensor(sensor.id!) + } + } + + return { isUpdated: true } } //********************************** export default function EditBoxSensors() { - const data = useLoaderData(); - const actionData = useActionData(); - - const [sensorsData, setSensorsData] = useState(data); - - /* temp impl. until figuring out how to updating state of nested objects */ - const [tepmState, setTepmState] = useState(false); - //* to view toast on edit-page - const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>(); - - React.useEffect(() => { - //* if sensors data were updated successfully - if (actionData && actionData?.isUpdated) { - //* show notification when data is successfully updated - setToastOpen(true); - // window.location.reload(); - //* reset sensor data elements - for (let index = 0; index < sensorsData.length; index++) { - const sensor = sensorsData[index]; - if (sensor.new == true && sensor.notValidInput == true) { - sensorsData.splice(index, 1); - } else if (sensor.deleted) { - sensorsData.splice(index, 1); - } else if (sensor.new == true && sensor.notValidInput == true) { - sensorsData.splice(index, 1); - } else if (sensor.editing == true) { - delete sensor.editing; - } - } - } - }, [actionData, sensorsData, setToastOpen]); - - return ( -
- {/* sensor form */} -
-
- {/* Form */} -
- {/* Heading */} -
- {/* Title */} -
-
-

Sensor

-
-
- {/* Add button */} - - {/* Save button */} - -
-
-
- - {/* divider */} -
- -
-

- Data measured by sensors that you are going to delete will be - deleted as well. If you add new sensors, don't forget to - retrieve your new script (see tab 'Script'). -

-
- -
    - {sensorsData?.map((sensor: any, index: number) => { - return ( -
  • -
    - {/* left side -> sensor icons list */} -
    - {sensor?.editing ? ( - -
    - {/* view icon */} - - - {/* down arrow icon */} - - - - - - - {iconsList?.map((icon: any) => { - const Icon = icon.name; - return ( - { - setTepmState(!tepmState); - sensor.icon = icon.id; - }} - > - - - ); - })} - - - -
    -
    - ) : ( - - {sensor.icon - ? getIcon(sensor.icon) - : assignIcon(sensor.sensorType, sensor.title)} - - )} -
    - {/* middle -> sensor attributes */} -
    - {/* shown by default */} - {!sensor?.editing && ( - - - Phenomenon: - - {sensor?.title} - - - ID: - - {sensor?.id} - - - - Unit: - - {sensor?.unit} - - - - Type: - - {sensor?.sensorType} - - - - )} - - {/* shown when edit button clicked */} - {sensor?.editing && ( -
    -
    - - { - setTepmState(!tepmState); - sensor.title = e.target.value; - if (sensor.title.length === 0) { - sensor.notValidInput = true; - } else { - sensor.notValidInput = false; - } - }} - /> -
    -
    - - { - setTepmState(!tepmState); - sensor.sensorType = e.target.value; - if (sensor.sensorType.length === 0) { - sensor.notValidInput = true; - } else { - sensor.notValidInput = false; - } - }} - /> -
    -
    - - { - setTepmState(!tepmState); - sensor.unit = e.target.value; - if (sensor.unit.length === 0) { - sensor.notValidInput = true; - } else { - sensor.notValidInput = false; - } - }} - /> -
    -
    - )} -
    - - {/* right side -> Save, delete, cancel buttons */} -
    - {/* buttons shown by default */} - - {/* warning text - delete */} - {sensor?.deleting && ( - - This sensor will be deleted. - - )} - - {/* undo button */} - {sensor?.deleting && ( - - )} - - {!sensor?.editing && !sensor?.deleting && ( - - {/* edit button */} - {/* ToDo: why onClick not updating the state unless dummy unrelated state is updated */} - - - {/* delete button */} - - - )} - - - {sensor?.editing && ( - - {/* invalid input text */} - {sensor?.notValidInput && ( - - Please fill out all required fields. - - )} - - {/* save button */} - - - {/* cancel button */} - - - )} -
    -
    -
  • - ); - })} -
- - {/* As there's no way to send data wiht form on submit to action (see: https://github.com/remix-run/react-router/discussions/10264) */} - -
-
-
-
- ); + const data = useLoaderData() + const actionData = useActionData() + const [sensorsData, setSensorsData] = useState( + data as SensorData[], + ) + const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>() + + // Helper to update a sensor immutably + const updateSensorState = useCallback( + (index: number, updates: Partial) => { + setSensorsData((prev) => + prev.map((sensor, i) => + i === index ? { ...sensor, ...updates } : sensor, + ), + ) + }, + [], + ) + + // Helper to remove a sensor from state + const removeSensorFromState = useCallback((index: number) => { + setSensorsData((prev) => prev.filter((_, i) => i !== index)) + }, []) + + // Helper to add a new sensor + const addNewSensorToState = useCallback(() => { + setSensorsData((prev) => [ + ...prev, + { + title: '', + unit: '', + sensorType: '', + editing: true, + new: true, + notValidInput: true, + }, + ]) + }, []) + + // Helper to validate sensor fields + const validateSensor = (sensor: SensorData): boolean => { + return Boolean(sensor.title && sensor.unit && sensor.sensorType) + } + + // Helper to reset sensor to original data + const resetSensor = useCallback( + (index: number) => { + const originalData = (data as SensorData[])[index] + updateSensorState(index, { + editing: false, + title: originalData.title, + unit: originalData.unit, + sensorType: originalData.sensorType, + notValidInput: false, + }) + }, + [data, updateSensorState], + ) + + React.useEffect(() => { + if (actionData?.isUpdated) { + setToastOpen(true) + + // Clean up state after successful update + setSensorsData((prev) => + prev + .filter((sensor) => !sensor.deleted) // Remove deleted sensors + .map((sensor) => ({ + ...sensor, + editing: false, + edited: false, + new: false, + notValidInput: false, + })), + ) + } + }, [actionData, setToastOpen]) + + return ( +
+
+
+
+ {/* Heading */} +
+
+
+

Sensor

+
+
+ {/* Add button */} + + {/* Save button */} + +
+
+
+ + {/* divider */} +
+ +
+

+ Data measured by sensors that you are going to delete will be + deleted as well. If you add new sensors, don't forget to + retrieve your new script (see tab 'Script'). +

+
+ +
    + {sensorsData?.map((sensor, index) => ( +
  • +
    + {/* Left side -> sensor icons */} +
    + {sensor?.editing ? ( + +
    + {/* View icon */} + + + {/* Icon dropdown */} + + + + + + + {iconsList?.map((icon: any) => { + const Icon = icon.name + return ( + + updateSensorState(index, { + icon: icon.id, + }) + } + > + + + ) + })} + + + +
    +
    + ) : ( + + {sensor.icon + ? getIcon(sensor.icon) + : assignIcon( + sensor.sensorType ?? '', + sensor.title ?? '', + )} + + )} +
    + + {/* Middle -> sensor attributes */} +
    + {/* Display mode */} + {!sensor?.editing && ( + + + Phenomenon: + + {sensor?.title} + + + ID: + + {sensor?.id} + + + + Unit: + + {sensor?.unit} + + + + Type: + + {sensor?.sensorType} + + + + )} + + {/* Edit mode */} + {sensor?.editing && ( +
    + {/* Phenomenon */} +
    + + { + const value = e.target.value + updateSensorState(index, { + title: value, + notValidInput: !validateSensor({ + ...sensor, + title: value, + }), + }) + }} + /> +
    + + {/* Unit */} +
    + + { + const value = e.target.value + updateSensorState(index, { + unit: value, + notValidInput: !validateSensor({ + ...sensor, + unit: value, + }), + }) + }} + /> +
    + + {/* Type */} +
    + + { + const value = e.target.value + updateSensorState(index, { + sensorType: value, + notValidInput: !validateSensor({ + ...sensor, + sensorType: value, + }), + }) + }} + /> +
    +
    + )} +
    + + {/* Right side -> action buttons */} +
    + + {/* Delete warning */} + {sensor?.deleting && ( + <> + + This sensor will be deleted. + + + + )} + + {/* Default buttons (not editing, not deleting) */} + {!sensor?.editing && !sensor?.deleting && ( + + + + + + )} + + + {/* Editing buttons */} + {sensor?.editing && ( + + {sensor?.notValidInput && ( + + Please fill out all required fields. + + )} + + {/* Save button */} + + + {/* Cancel button */} + + + )} +
    +
    +
  • + ))} +
+ + {/* Hidden input for form submission */} + +
+
+
+
+ ) } export function ErrorBoundary() { - return ( -
- -
- ); + return ( +
+ +
+ ) } From 6b3ded552d36ac57a7b196a9678d890d6df979c6 Mon Sep 17 00:00:00 2001 From: JerryVincent Date: Wed, 14 Jan 2026 11:46:25 +0100 Subject: [PATCH 2/4] Revert "Merge remote-tracking branch 'origin/dev' into feat/editDeviceDetails" This reverts commit 0ba4a269f79787483700446707a369b4b3ed6693, reversing changes made to 0161d76116578506ae35f4eeb5e5be1bcfd10b03. --- .../device-detail/device-detail-box.tsx | 1287 +++++---- app/components/header/menu/index.tsx | 6 +- app/components/landing/footer.tsx | 202 +- app/components/landing/header/header.tsx | 7 +- app/components/landing/sections/connect.tsx | 15 +- app/components/landing/sections/features.tsx | 21 +- .../landing/sections/integrations.tsx | 13 +- app/components/landing/sections/partners.tsx | 6 +- .../landing/sections/pricing-plans.tsx | 12 +- app/components/landing/sections/tools.tsx | 4 +- app/components/mydevices/dt/columns.tsx | 210 +- app/components/mydevices/dt/data-table.tsx | 404 ++- app/components/nav-bar.tsx | 18 +- app/components/stepper/index.tsx | 68 + app/db.server.ts | 64 +- .../api-schemas/boxes-data-query-schema.ts | 176 -- app/lib/device-transform.ts | 181 +- app/lib/measurement-service.server.ts | 70 - app/models/device.server.ts | 1284 ++++----- app/models/measurement.query.server.ts | 165 -- app/models/measurement.server.ts | 446 ++- app/models/measurement.stream.server.ts | 60 - app/models/sensor.server.ts | 94 +- .../api.boxes.$deviceId.data.$sensorId.ts | 251 +- app/routes/api.boxes.data.ts | 134 - app/routes/api.boxes.ts | 142 +- app/routes/api.device.$deviceId.ts | 201 +- app/routes/api.ts | 422 +-- app/routes/device.$deviceId.edit.general.tsx | 18 +- app/routes/device_.tsx | 77 +- app/routes/explore.$deviceId.$sensorId.$.tsx | 7 +- app/routes/explore.forgot.tsx | 19 +- app/routes/explore.login.tsx | 14 +- app/routes/explore.tsx | 801 +++--- app/routes/profile.$username.tsx | 27 +- app/routes/settings.account.tsx | 373 ++- app/routes/settings.delete.tsx | 211 +- app/routes/settings.password.tsx | 395 +-- app/routes/settings.profile.photo.tsx | 11 +- app/routes/settings.profile.tsx | 37 +- app/routes/settings.tsx | 12 +- app/schema/enum.ts | 52 +- app/schema/sensor.ts | 3 +- app/utils/addon-definitions.ts | 19 - app/utils/csv.ts | 28 +- app/utils/model-definitions.ts | 201 +- drizzle/0023_red_chameleon.sql | 11 - drizzle/0024_first_kitty_pryde.sql | 11 - drizzle/meta/0022_snapshot.json | 2448 +++++++++-------- drizzle/meta/0023_snapshot.json | 2448 +++++++++-------- drizzle/meta/0024_snapshot.json | 1279 --------- drizzle/meta/_journal.json | 7 - package-lock.json | 12 - package.json | 1 - public/locales/de/connect.json | 8 - public/locales/de/data-table.json | 23 - public/locales/de/features.json | 11 - public/locales/de/footer.json | 8 - public/locales/de/header.json | 8 - public/locales/de/integrations.json | 7 - public/locales/de/login.json | 22 +- public/locales/de/menu.json | 1 - public/locales/de/navbar.json | 9 +- public/locales/de/partners.json | 4 - public/locales/de/pricing-plans.json | 7 - public/locales/de/profile.json | 8 - public/locales/de/settings.json | 57 - public/locales/de/tools.json | 3 - public/locales/en/connect.json | 8 - public/locales/en/data-table.json | 23 - public/locales/en/features.json | 11 - public/locales/en/footer.json | 8 - public/locales/en/header.json | 8 - public/locales/en/integrations.json | 7 - public/locales/en/login.json | 21 +- public/locales/en/menu.json | 1 - public/locales/en/navbar.json | 9 +- public/locales/en/partners.json | 4 - public/locales/en/pricing-plans.json | 7 - public/locales/en/profile.json | 8 - public/locales/en/settings.json | 57 - public/locales/en/tools.json | 3 - tests/models/device.server.spec.ts | 26 +- ...api.boxes.$deviceId.data.$sensorId.spec.ts | 2 +- .../api.boxes.$deviceId.locations.spec.ts | 1 + ....boxes.$deviceId.sensors.$sensorId.spec.ts | 2 +- .../api.boxes.$deviceId.sensors.spec.ts | 2 +- tests/routes/api.boxes.data.spec.ts | 466 ---- tests/routes/api.boxes.spec.ts | 2 +- tests/routes/api.device.feinstaub.spec.ts | 204 -- tests/routes/api.device.sensors.spec.ts | 356 --- tests/routes/api.devices.spec.ts | 341 +-- tests/routes/api.location.spec.ts | 1 + tests/routes/api.measurements.spec.ts | 2 +- tests/routes/api.tags.spec.ts | 4 +- tests/utils/measurement-server-helper.spec.ts | 2 +- 96 files changed, 6053 insertions(+), 10194 deletions(-) create mode 100644 app/components/stepper/index.tsx delete mode 100644 app/lib/api-schemas/boxes-data-query-schema.ts delete mode 100644 app/models/measurement.query.server.ts delete mode 100644 app/models/measurement.stream.server.ts delete mode 100644 app/routes/api.boxes.data.ts delete mode 100644 app/utils/addon-definitions.ts delete mode 100644 drizzle/0023_red_chameleon.sql delete mode 100644 drizzle/0024_first_kitty_pryde.sql delete mode 100644 drizzle/meta/0024_snapshot.json delete mode 100644 public/locales/de/connect.json delete mode 100644 public/locales/de/data-table.json delete mode 100644 public/locales/de/features.json delete mode 100644 public/locales/de/footer.json delete mode 100644 public/locales/de/header.json delete mode 100644 public/locales/de/integrations.json delete mode 100644 public/locales/de/partners.json delete mode 100644 public/locales/de/pricing-plans.json delete mode 100644 public/locales/de/profile.json delete mode 100644 public/locales/de/settings.json delete mode 100644 public/locales/de/tools.json delete mode 100644 public/locales/en/connect.json delete mode 100644 public/locales/en/data-table.json delete mode 100644 public/locales/en/features.json delete mode 100644 public/locales/en/footer.json delete mode 100644 public/locales/en/header.json delete mode 100644 public/locales/en/integrations.json delete mode 100644 public/locales/en/partners.json delete mode 100644 public/locales/en/pricing-plans.json delete mode 100644 public/locales/en/profile.json delete mode 100644 public/locales/en/settings.json delete mode 100644 public/locales/en/tools.json delete mode 100644 tests/routes/api.boxes.data.spec.ts delete mode 100644 tests/routes/api.device.feinstaub.spec.ts delete mode 100644 tests/routes/api.device.sensors.spec.ts diff --git a/app/components/device-detail/device-detail-box.tsx b/app/components/device-detail/device-detail-box.tsx index e4f8b5b7..7b810263 100644 --- a/app/components/device-detail/device-detail-box.tsx +++ b/app/components/device-detail/device-detail-box.tsx @@ -1,688 +1,675 @@ -import clsx from 'clsx' -import { format, formatDistanceToNow } from 'date-fns' +import clsx from "clsx"; +import { format, formatDistanceToNow } from "date-fns"; import { - ChevronUp, - Minus, - Share2, - XSquare, - EllipsisVertical, - X, - ExternalLink, - Scale, - Archive, - Cpu, - Rss, - CalendarPlus, - Hash, - LandPlot, - Image as ImageIcon, -} from 'lucide-react' -import { Fragment, useEffect, useRef, useState } from 'react' -import { isTablet, isBrowser } from 'react-device-detect' -import Draggable, { type DraggableData } from 'react-draggable' + ChevronUp, + Minus, + Share2, + XSquare, + EllipsisVertical, + X, + ExternalLink, + Scale, + Archive, + Cpu, + Rss, + CalendarPlus, + Hash, + LandPlot, + Image as ImageIcon, +} from "lucide-react"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { isTablet, isBrowser } from "react-device-detect"; +import Draggable, { type DraggableData } from "react-draggable"; import { - useLoaderData, - useMatches, - useNavigate, - useNavigation, - useParams, - useSearchParams, - Link, -} from 'react-router' -import SensorIcon from '../sensor-icon' -import Spinner from '../spinner' + useLoaderData, + useMatches, + useNavigate, + useNavigation, + useParams, + useSearchParams, + Link, +} from "react-router"; +import SensorIcon from "../sensor-icon"; +import Spinner from "../spinner"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '../ui/accordion' -import { Alert, AlertDescription, AlertTitle } from '../ui/alert' + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "../ui/accordion"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '../ui/alert-dialog' -import { Badge } from '../ui/badge' -import { Button } from '../ui/button' + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../ui/alert-dialog"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from '../ui/card' + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "../ui/card"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '../ui/dropdown-menu' -import { Separator } from '../ui/separator' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { Separator } from "../ui/separator"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '../ui/tooltip' -import { useToast } from '../ui/use-toast' -import EntryLogs from './entry-logs' -import ShareLink from './share-link' -import { useGlobalCompareMode } from './useGlobalCompareMode' -import { type loader } from '~/routes/explore.$deviceId' -import { type SensorWithLatestMeasurement } from '~/schema' -import { getArchiveLink } from '~/utils/device' + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import { useToast } from "../ui/use-toast"; +import EntryLogs from "./entry-logs"; +import ShareLink from "./share-link"; +import { useGlobalCompareMode } from "./useGlobalCompareMode"; +import { type loader } from "~/routes/explore.$deviceId"; +import { type SensorWithLatestMeasurement } from "~/schema"; +import { getArchiveLink } from "~/utils/device"; export interface MeasurementProps { - sensorId: string - time: Date - value: string - min_value: string - max_value: string + sensorId: string; + time: Date; + value: string; + min_value: string; + max_value: string; } export default function DeviceDetailBox() { - const navigation = useNavigation() - const navigate = useNavigate() - const matches = useMatches() - const { toast } = useToast() + const navigation = useNavigation(); + const navigate = useNavigate(); + const matches = useMatches(); + const { toast } = useToast(); - const sensorIds = new Set() + const sensorIds = new Set(); - const data = useLoaderData() - const nodeRef = useRef(null) - // state variables - const [open, setOpen] = useState(true) - const [offsetPositionX, setOffsetPositionX] = useState(0) - const [offsetPositionY, setOffsetPositionY] = useState(0) - const [compareMode, setCompareMode] = useGlobalCompareMode() - const [refreshOn] = useState(false) - const [refreshSecond, setRefreshSecond] = useState(59) + const data = useLoaderData(); + const nodeRef = useRef(null); + // state variables + const [open, setOpen] = useState(true); + const [offsetPositionX, setOffsetPositionX] = useState(0); + const [offsetPositionY, setOffsetPositionY] = useState(0); + const [compareMode, setCompareMode] = useGlobalCompareMode(); + const [refreshOn] = useState(false); + const [refreshSecond, setRefreshSecond] = useState(59); - const [sensors, setSensors] = useState() - useEffect(() => { - const sortedSensors = [...(data.sensors as any)].sort( - (a, b) => (a.id as unknown as number) - (b.id as unknown as number), - ) - setSensors(sortedSensors) - }, [data]) + const [sensors, setSensors] = useState(); + useEffect(() => { + const sortedSensors = [...(data.sensors as any)].sort( + (a, b) => (a.id as unknown as number) - (b.id as unknown as number) + ); + setSensors(sortedSensors); + }, [data]); - const [searchParams] = useSearchParams() + const [searchParams] = useSearchParams(); - const { deviceId } = useParams() // Get the deviceId from the URL params + const { deviceId } = useParams(); // Get the deviceId from the URL params - const createSensorLink = (sensorIdToBeSelected: string) => { - const lastSegment = matches[matches.length - 1]?.params?.['*'] - if (lastSegment) { - const secondLastSegment = matches[matches.length - 2]?.params?.sensorId - sensorIds.add(secondLastSegment) - sensorIds.add(lastSegment) - } else { - const lastSegment = matches[matches.length - 1]?.params?.sensorId - if (lastSegment) { - sensorIds.add(lastSegment) - } - } + const createSensorLink = (sensorIdToBeSelected: string) => { + const lastSegment = matches[matches.length - 1]?.params?.["*"]; + if (lastSegment) { + const secondLastSegment = matches[matches.length - 2]?.params?.sensorId; + sensorIds.add(secondLastSegment); + sensorIds.add(lastSegment); + } else { + const lastSegment = matches[matches.length - 1]?.params?.sensorId; + if (lastSegment) { + sensorIds.add(lastSegment); + } + } - // If sensorIdToBeSelected is second selected sensor - if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 2) { - const clonedSet = new Set(sensorIds) - clonedSet.delete(sensorIdToBeSelected) - return `/explore/${deviceId}/${Array.from(clonedSet).join('/')}?${searchParams.toString()}` - } else if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 1) { - return `/explore/${deviceId}?${searchParams.toString()}` - } else if (sensorIds.size === 0) { - return `/explore/${deviceId}/${sensorIdToBeSelected}?${searchParams.toString()}` - } else if (sensorIds.size === 1) { - return `/explore/${deviceId}/${Array.from(sensorIds).join('/')}/${sensorIdToBeSelected}?${searchParams.toString()}` - } + // If sensorIdToBeSelected is second selected sensor + if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 2) { + const clonedSet = new Set(sensorIds); + clonedSet.delete(sensorIdToBeSelected); + return `/explore/${deviceId}/${Array.from(clonedSet).join("/")}?${searchParams.toString()}`; + } else if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 1) { + return `/explore/${deviceId}?${searchParams.toString()}`; + } else if (sensorIds.size === 0) { + return `/explore/${deviceId}/${sensorIdToBeSelected}?${searchParams.toString()}`; + } else if (sensorIds.size === 1) { + return `/explore/${deviceId}/${Array.from(sensorIds).join("/")}/${sensorIdToBeSelected}?${searchParams.toString()}`; + } - return '' - } + return ""; + }; - const isSensorActive = (sensorId: string) => { - if (sensorIds.has(sensorId)) { - return 'bg-green-100 dark:bg-dark-green' - } + const isSensorActive = (sensorId: string) => { + if (sensorIds.has(sensorId)) { + return "bg-green-100 dark:bg-dark-green"; + } - return 'hover:bg-muted' - } + return "hover:bg-muted"; + }; - function handleDrag(_e: any, data: DraggableData) { - setOffsetPositionX(data.x) - setOffsetPositionY(data.y) - } + function handleDrag(_e: any, data: DraggableData) { + setOffsetPositionX(data.x); + setOffsetPositionY(data.y); + } - const addLineBreaks = (text: string) => - text.split('\\n').map((text, index) => ( - - {text} -
-
- )) + const addLineBreaks = (text: string) => + text.split("\\n").map((text, index) => ( + + {text} +
+
+ )); - useEffect(() => { - let interval: any = null - if (refreshOn) { - if (refreshSecond == 0) { - setRefreshSecond(59) - } - interval = setInterval(() => { - setRefreshSecond((refreshSecond) => refreshSecond - 1) - }, 1000) - } else if (!refreshOn) { - clearInterval(interval) - } - return () => clearInterval(interval) - }, [refreshOn, refreshSecond]) + useEffect(() => { + let interval: any = null; + if (refreshOn) { + if (refreshSecond == 0) { + setRefreshSecond(59); + } + interval = setInterval(() => { + setRefreshSecond((refreshSecond) => refreshSecond - 1); + }, 1000); + } else if (!refreshOn) { + clearInterval(interval); + } + return () => clearInterval(interval); + }, [refreshOn, refreshSecond]); - if (!data.device) return null + if (!data.device) return null; - return ( - <> - {open && ( - } - defaultPosition={{ x: offsetPositionX, y: offsetPositionY }} - onDrag={handleDrag} - bounds="#osem" - handle="#deviceDetailBoxTop" - disabled={!isBrowser && !isTablet} - > -
-
- {navigation.state === 'loading' && ( -
- -
- )} -
-
-
- {data.device.name} -
- - - - - - - Share this link - - - - Close - - - - - - - - - Actions - - - - Compare - - - - - - Archive - - - - - - - - External Link - - - - - + return ( + <> + {open && ( + } + defaultPosition={{ x: offsetPositionX, y: offsetPositionY }} + onDrag={handleDrag} + bounds="#osem" + handle="#deviceDetailBoxTop" + disabled={!isBrowser && !isTablet} + > +
+
+ {navigation.state === "loading" && ( +
+ +
+ )} +
+
+
+ {data.device.name} +
+ + + + + + + Share this link + + + + Close + + + + + + + + + Actions + + + + Compare + + + + + + Archive + + + + + + + + External Link + + + + + - setOpen(false)} - /> - { - void navigate({ - pathname: '/explore', - search: searchParams.toString(), - }) - }} - /> -
-
-
-
- {data.device.image ? ( - device_image - ) : ( -
- -
- )} -
-
- - - - - - - {data.device.expiresAt && ( - <> - - - - )} -
-
- {data.device.tags && data.device.tags.length > 0 && ( -
-
-
- Tags -
-
- -
- {data.device.tags.map((tag: string) => ( - { - event.stopPropagation() + setOpen(false)} + /> + { + void navigate({ + pathname: "/explore", + search: searchParams.toString(), + }); + }} + /> +
+
+
+
+ {data.device.image ? ( + device_image + ) : ( +
+ +
+ )} +
+
+ + + + + + + {data.device.expiresAt && ( + <> + + + + )} +
+
+ {data.device.tags && data.device.tags.length > 0 && ( +
+
+
+ Tags +
+
+ +
+ {data.device.tags.map((tag: string) => ( + { + event.stopPropagation(); - const currentParams = new URLSearchParams( - searchParams.toString(), - ) + const currentParams = new URLSearchParams( + searchParams.toString() + ); - // Safely retrieve and parse the current tags - const currentTags = - currentParams.get('tags')?.split(',') || [] + // Safely retrieve and parse the current tags + const currentTags = + currentParams.get("tags")?.split(",") || []; - // Toggle the tag in the list - const updatedTags = currentTags.includes(tag) - ? currentTags.filter((t) => t !== tag) // Remove if already present - : [...currentTags, tag] // Add if not present + // Toggle the tag in the list + const updatedTags = currentTags.includes(tag) + ? currentTags.filter((t) => t !== tag) // Remove if already present + : [...currentTags, tag]; // Add if not present - // Update the tags parameter or remove it if empty - if (updatedTags.length > 0) { - currentParams.set( - 'tags', - updatedTags.join(','), - ) - } else { - currentParams.delete('tags') - } + // Update the tags parameter or remove it if empty + if (updatedTags.length > 0) { + currentParams.set( + "tags", + updatedTags.join(",") + ); + } else { + currentParams.delete("tags"); + } - // Update the URL with the new search params - void navigate({ - search: currentParams.toString(), - }) - }} - > - {tag} - - ))} -
-
-
-
- )} - - {data.device.logEntries.length > 0 && ( - <> - - - - )} - {data.device.description && ( - - - - Description - - - {addLineBreaks(data.device.description)} - - - - )} - - - - Sensors - - -
-
- {sensors && - sensors.map( - (sensor: SensorWithLatestMeasurement) => { - const sensorLink = createSensorLink(sensor.id) - if (sensorLink === '') { - return ( - - toast({ - title: - 'Cant select more than 2 sensors', - description: - 'Deselect one sensor to select another', - variant: 'destructive', - }) - } - > - - - ) - } - return ( - - - - - - ) - }, - )} -
-
-
-
-
-
-
-
- - )} - {compareMode && ( - - { - setCompareMode(!compareMode) - setOpen(true) - }} - /> - Compare devices - - Choose a device from the map to compare with. - - - )} - {!open && ( -
{ - setOpen(true) - }} - className="absolute bottom-[10px] left-4 flex cursor-pointer rounded-xl border border-gray-100 bg-white shadow-lg transition-colors duration-300 ease-in-out hover:brightness-90 dark:bg-zinc-800 dark:text-zinc-200 dark:opacity-90 sm:bottom-[30px] sm:left-[10px]" - > - - - -
- -
-
- -

Open device details

-
-
-
-
- )} - - ) + // Update the URL with the new search params + void navigate({ + search: currentParams.toString(), + }); + }} + > + {tag} + + ))} +
+
+
+
+ )} + + {data.device.logEntries.length > 0 && ( + <> + + + + )} + {data.device.description && ( + + + + Description + + + {addLineBreaks(data.device.description)} + + + + )} + + + + Sensors + + +
+
+ {sensors && + sensors.map( + (sensor: SensorWithLatestMeasurement) => { + const sensorLink = createSensorLink(sensor.id); + if (sensorLink === "") { + return ( + + toast({ + title: + "Cant select more than 2 sensors", + description: + "Deselect one sensor to select another", + variant: "destructive", + }) + } + > + + + ); + } + return ( + + + + + + ); + } + )} +
+
+
+
+
+
+
+
+
+ )} + {compareMode && ( + + { + setCompareMode(!compareMode); + setOpen(true); + }} + /> + Compare devices + + Choose a device from the map to compare with. + + + )} + {!open && ( +
{ + setOpen(true); + }} + className="absolute bottom-[10px] left-4 flex cursor-pointer rounded-xl border border-gray-100 bg-white shadow-lg transition-colors duration-300 ease-in-out hover:brightness-90 dark:bg-zinc-800 dark:text-zinc-200 dark:opacity-90 sm:bottom-[30px] sm:left-[10px]" + > + + + +
+ +
+
+ +

Open device details

+
+
+
+
+ )} + + ); } const InfoItem = ({ - icon: Icon, - title, - text, + icon: Icon, + title, + text, }: { - icon: React.ElementType - title: string - text?: string + icon: React.ElementType; + title: string; + text?: string; }) => - text && ( -
-
{title}
-
- - {text} -
-
- ) + text && ( +
+
{title}
+
+ + {text} +
+
+ ); diff --git a/app/components/header/menu/index.tsx b/app/components/header/menu/index.tsx index 64389ff9..fe31a44a 100644 --- a/app/components/header/menu/index.tsx +++ b/app/components/header/menu/index.tsx @@ -97,7 +97,7 @@ export default function Menu() { - {t("explore_label")} + {"Explore"} )} @@ -105,7 +105,7 @@ export default function Menu() { - {t("profile_label")} + Profile )} @@ -114,7 +114,7 @@ export default function Menu() { - {t("settings_label")} + {"Settings"} diff --git a/app/components/landing/footer.tsx b/app/components/landing/footer.tsx index 2d0178f9..71388542 100644 --- a/app/components/landing/footer.tsx +++ b/app/components/landing/footer.tsx @@ -1,106 +1,100 @@ -import { useTranslation } from 'react-i18next' - export default function Footer() { - const { t } = useTranslation('footer') - return ( - - ) + return ( + + ); } diff --git a/app/components/landing/header/header.tsx b/app/components/landing/header/header.tsx index e11e23ef..cc61b57c 100644 --- a/app/components/landing/header/header.tsx +++ b/app/components/landing/header/header.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Link } from "react-router"; // import { ModeToggle } from "../../mode-toggle"; import LanguageSelector from "./language-selector"; -import { useTranslation } from "react-i18next"; const links = [ { @@ -34,8 +33,6 @@ const links = [ export default function Header() { const [openMenu, setOpenMenu] = useState(false); - const { t } = useTranslation("header"); - return (
diff --git a/app/components/landing/sections/connect.tsx b/app/components/landing/sections/connect.tsx index 80dabba9..68511d70 100644 --- a/app/components/landing/sections/connect.tsx +++ b/app/components/landing/sections/connect.tsx @@ -1,8 +1,6 @@ import { BookA, Wrench } from "lucide-react"; -import { useTranslation } from "react-i18next"; export default function Connect() { - const { t } = useTranslation('connect') return (
- {t("title")} + Connect any device
- {t("description")} + We support preconfigured devices by some vendors but you can always + registered your custom hardware and sensor setup.
@@ -27,7 +26,7 @@ export default function Connect() { className="flex items-center gap-3" > - {t("senseBox")} + senseBox
@@ -38,7 +37,7 @@ export default function Connect() { className="flex items-center gap-3" > - {t("hackAIR")} + hackAIR
@@ -49,7 +48,7 @@ export default function Connect() { className="flex items-center gap-3" > - {t("Sensor.Community")} + Sensor.Community
@@ -60,7 +59,7 @@ export default function Connect() { className="flex items-center gap-3" > - {t("Custom")} + Custom
diff --git a/app/components/landing/sections/features.tsx b/app/components/landing/sections/features.tsx index 8d57e70f..7abc0401 100644 --- a/app/components/landing/sections/features.tsx +++ b/app/components/landing/sections/features.tsx @@ -7,10 +7,8 @@ import { Terminal, Trash, } from "lucide-react"; -import { useTranslation } from "react-i18next"; export default function Features() { - const { t } = useTranslation('features') return (
- {t("features")} + Features
- {t("description")} + The openSenseMap platform has a lot to offer that makes + discoverability and sharing of environmental and sensor data easy.
@@ -30,43 +29,43 @@ export default function Features() {
- {t("dataAggregation")} + Data aggregation
- {t("noDataRetention")} + No data retention
- {t("dataPublished")} + Data published as ODbL
- {t("discoverDevices")} + Discover devices
- {t("compareDevices")} + Compare devices
- {t("downloadOptions")} + Download options
- {t("httpRestApi")} + HTTP REST API
diff --git a/app/components/landing/sections/integrations.tsx b/app/components/landing/sections/integrations.tsx index 184f5425..66573a90 100644 --- a/app/components/landing/sections/integrations.tsx +++ b/app/components/landing/sections/integrations.tsx @@ -1,8 +1,6 @@ import { ArrowUpDown, RadioTower, Unplug } from "lucide-react"; -import { useTranslation } from "react-i18next"; export default function Integrations() { - const { t } = useTranslation('integrations') return (
- {t("title")} + Integrations
- {t("description")} + We support different data communication protocols and offer specific + integrations for them.
@@ -27,7 +26,7 @@ export default function Integrations() { className="flex items-center gap-3" > - {t("HTTP API")} + HTTP API
@@ -38,7 +37,7 @@ export default function Integrations() { className="flex items-center gap-3" > - {t("MQTT")} + MQTT
@@ -49,7 +48,7 @@ export default function Integrations() { className="flex items-center gap-3" > - {t("TTN")} + TTN v3 (LoRa WAN)
diff --git a/app/components/landing/sections/partners.tsx b/app/components/landing/sections/partners.tsx index 7dbb3e64..ae4473a7 100644 --- a/app/components/landing/sections/partners.tsx +++ b/app/components/landing/sections/partners.tsx @@ -1,5 +1,4 @@ import { motion } from "framer-motion"; -import { useTranslation } from "react-i18next"; import { type Partner } from "~/lib/directus"; type PartnersProps = { @@ -7,7 +6,6 @@ type PartnersProps = { }; export default function Partners({ data }: PartnersProps) { - const { t } = useTranslation('partners') return (

- {t("Partners")} + Partners

@@ -61,7 +59,7 @@ export default function Partners({ data }: PartnersProps) { }} className="flex flex-col items-center justify-center" > -

{t("hosted")}

+

hosted by

openSenseLab Logo
-

{t("Pricing")}

+

Pricing

- {(t("kidding"))}
- {(t("contribution"))} + Just kidding, openSenseMap is free and open-source.

+ You can still make your contribution.

@@ -23,7 +21,7 @@ export default function PricingPlans() { className="flex items-center justify-center border-2 border-solid rounded-sm px-4 py-2 hover:cursor-pointer" > - {t("star")} + Give us a star
diff --git a/app/components/landing/sections/tools.tsx b/app/components/landing/sections/tools.tsx index fc485ffb..c734d81b 100644 --- a/app/components/landing/sections/tools.tsx +++ b/app/components/landing/sections/tools.tsx @@ -1,9 +1,7 @@ import { motion } from "framer-motion"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useTranslation } from "react-i18next"; export default function Tools() { - const { t } = useTranslation('tools') const tools = [ { name: "Tool 1", @@ -78,7 +76,7 @@ export default function Tools() { className="dark:border-blue-200 h-full w-[80%] rounded-lg border-8 border-solid border-blue-100 object-contain" > - {t("notSupported")} + Your browser does not support the video tag.
diff --git a/app/components/mydevices/dt/columns.tsx b/app/components/mydevices/dt/columns.tsx index 20c09175..894baaca 100644 --- a/app/components/mydevices/dt/columns.tsx +++ b/app/components/mydevices/dt/columns.tsx @@ -2,7 +2,6 @@ import { type ColumnDef } from '@tanstack/react-table' import { ArrowUpDown, ClipboardCopy, Ellipsis } from 'lucide-react' -import { type UseTranslationResponse } from 'react-i18next' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -23,42 +22,38 @@ export type SenseBox = { const colStyle = 'pl-0 dark:text-white' -export function getColumns( - useTranslation: UseTranslationResponse<'data-table', any>, -): ColumnDef[] { - const { t } = useTranslation - return [ - { - accessorKey: 'name', - header: ({ column }) => { - return ( - - ) - }, +export const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => { + return ( + + ) }, - { - accessorKey: 'exposure', - header: ({ column }) => { - return ( - - ) - }, + }, + { + accessorKey: 'exposure', + header: ({ column }) => { + return ( + + ) }, - /* { + }, + /* { accessorKey: "model", header: ({ column }) => { return ( @@ -67,89 +62,82 @@ export function getColumns( onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className={styleVal} > - {t("model")} + Model ); }, }, */ - { - accessorKey: 'id', - header: () => ( -
{t('sensebox_id')}
- ), - cell: ({ row }) => { - const senseBox = row.original + { + accessorKey: 'id', + header: () =>
Sensebox ID
, + cell: ({ row }) => { + const senseBox = row.original - return ( - //
-
- - {senseBox?.id} - - navigator.clipboard.writeText(senseBox?.id)} - className="ml-[6px] mr-1 inline-block h-4 w-4 cursor-pointer align-text-bottom text-[#818a91] dark:text-white" - /> -
- ) - }, + return ( + //
+
+ + {senseBox?.id} + + navigator.clipboard.writeText(senseBox?.id)} + className="ml-[6px] mr-1 inline-block h-4 w-4 cursor-pointer align-text-bottom text-[#818a91] dark:text-white" + /> +
+ ) }, - { - id: 'actions', - header: () => ( -
{t('actions')}
- ), - cell: ({ row }) => { - const senseBox = row.original + }, + { + id: 'actions', + header: () =>
Actions
, + cell: ({ row }) => { + const senseBox = row.original - return ( - - - - - - Actions - - - {t('overview')} - - - {t('show_on_map')} - - - {t('edit')} - - - - {t('data_upload')} - - - - - {t('support')} - - - navigator.clipboard.writeText(senseBox?.id)} - className="cursor-pointer" + return ( + + + + + + Actions + + + Overview + + + Show on map + + + Edit + + + Data upload + + + - {t('copy_id')} - - - - ) - }, + Support + + + navigator.clipboard.writeText(senseBox?.id)} + className="cursor-pointer" + > + Copy ID + + + + ) }, - ] -} + }, +] diff --git a/app/components/mydevices/dt/data-table.tsx b/app/components/mydevices/dt/data-table.tsx index 4fd1c2f3..d43272a3 100644 --- a/app/components/mydevices/dt/data-table.tsx +++ b/app/components/mydevices/dt/data-table.tsx @@ -1,219 +1,215 @@ -'use client' +"use client"; +import { + type ColumnDef, + type ColumnFiltersState, + type SortingState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; import { - type ColumnDef, - type ColumnFiltersState, - type SortingState, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, -} from '@tanstack/react-table' + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { - ChevronLeft, - ChevronRight, - ChevronsLeft, - ChevronsRight, -} from 'lucide-react' -import React from 'react' -import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from '@/components/ui/select' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; interface DataTableProps { - columns: ColumnDef[] - data: TData[] + columns: ColumnDef[]; + data: TData[]; } export function DataTable({ - columns, - data, + columns, + data, }: DataTableProps) { - const [sorting, setSorting] = React.useState([]) - const [columnFilters, setColumnFilters] = React.useState( - [], - ) - - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - state: { - sorting, - columnFilters, - }, - initialState: { - pagination: { - pageSize: 5, - }, - }, - }) - const tableColsWidth = [30, 30, 40] + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState( + [], + ); - const { t } = useTranslation('data-table') + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + state: { + sorting, + columnFilters, + }, + initialState: { + pagination: { + pageSize: 5, + }, + }, + }); + const tableColsWidth = [30, 30, 40]; - return ( -
-
- - table.getColumn('name')?.setFilterValue(event.target.value) - } - className="max-w-sm dark:border-white dark:text-white" - /> -
+ return ( +
+
+ + table.getColumn("name")?.setFilterValue(event.target.value) + } + className="max-w-sm dark:text-white dark:border-white" + /> +
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ) - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell, index) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - )) - ) : ( - - - {t('no_results')} - - - )} - -
-
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell, index) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
-
-
-
- {t('rows_per_page')} - -
-
- {t('page')} - {` ${table.getState().pagination.pageIndex + 1} `} - {t('of')} - {` ${table.getPageCount() ?? 10}`} -
-
- - - - -
-
-
-
- ) +
+
+
+ Rows per page + +
+
+ {`Page ${table.getState().pagination.pageIndex + 1} of ${ + table.getPageCount() ?? 10 + }`} +
+
+ + + + +
+
+
+
+ ); } diff --git a/app/components/nav-bar.tsx b/app/components/nav-bar.tsx index 5f02e348..bc2db917 100644 --- a/app/components/nav-bar.tsx +++ b/app/components/nav-bar.tsx @@ -11,32 +11,26 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { useOptionalUser } from '~/utils' -import { useTranslation } from 'react-i18next' export function NavBar() { - const { t } = useTranslation('navbar') const location = useLocation() - // User is optional - // If no user render Login button - const user = useOptionalUser() const parts = location.pathname .split('/') .slice(1) .map((item) => { const decoded = decodeURIComponent(item) - - // respect the way the username is written, taking - // it from the user model instead of the url - if (item.toLowerCase() === user?.name.toLowerCase()) return user?.name - - return t(decoded.charAt(0).toUpperCase() + decoded.slice(1)) + return decoded.charAt(0).toUpperCase() + decoded.slice(1) }) // prevents empty parts from showing + // User is optional + // If no user render Login button + const user = useOptionalUser() + return (
- + osem Logo diff --git a/app/components/stepper/index.tsx b/app/components/stepper/index.tsx new file mode 100644 index 00000000..118d0adf --- /dev/null +++ b/app/components/stepper/index.tsx @@ -0,0 +1,68 @@ +import clsx from "clsx"; +import { Link } from "react-router"; + +interface Step { + title: string; + longTitle?: string; +} + +interface SearchProps { + setStep: (step: number) => void; + steps: Step[]; + activeStep: number; + activatedSteps: number[]; +} + +export default function Stepper(props: SearchProps) { + return ( +
+ {/* Osem Logo*/} + + osem Logo + {/* + openSenseMap + */} + +
    + {props.steps.map((step: Step, index: number) => ( + + ))} +
+
+ ); +} diff --git a/app/db.server.ts b/app/db.server.ts index a032ea7d..4eb9ae51 100644 --- a/app/db.server.ts +++ b/app/db.server.ts @@ -1,45 +1,45 @@ -import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js' -import postgres, { type Sql } from 'postgres' -import invariant from 'tiny-invariant' -import * as schema from './schema' +import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import invariant from "tiny-invariant"; +import * as schema from "./schema"; + +let drizzleClient: PostgresJsDatabase; -let drizzleClient: PostgresJsDatabase -let pg: Sql declare global { - var __db__: - | { - drizzle: PostgresJsDatabase - pg: Sql - } - | undefined + var __db__: PostgresJsDatabase; } -if (process.env.NODE_ENV === 'production') { - const { drizzle, pg: rawPg } = initClient() - drizzleClient = drizzle - pg = rawPg +// this is needed because in development we don't want to restart +// the server with every change, but we want to make sure we don't +// create a new connection to the DB with every change either. +// in production we'll have a single connection to the DB. +if (process.env.NODE_ENV === "production") { + drizzleClient = getClient(); } else { - if (!global.__db__) { - global.__db__ = initClient() - } - drizzleClient = global.__db__.drizzle - pg = global.__db__.pg + if (!global.__db__) { + global.__db__ = getClient(); + } + drizzleClient = global.__db__; } -function initClient() { - const { DATABASE_URL } = process.env - invariant(typeof DATABASE_URL === 'string', 'DATABASE_URL env var not set') +function getClient() { + const { DATABASE_URL } = process.env; + invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set"); - const databaseUrl = new URL(DATABASE_URL) - console.log(`🔌 setting up drizzle client to ${databaseUrl.host}`) + const databaseUrl = new URL(DATABASE_URL); - const rawPg = postgres(DATABASE_URL, { - ssl: process.env.PG_CLIENT_SSL === 'true' ? true : false, - }) + console.log(`🔌 setting up drizzle client to ${databaseUrl.host}`); - const drizzleDb = drizzle(rawPg, { schema }) + // NOTE: during development if you change anything in this function, remember + // that this only runs once per server restart and won't automatically be + // re-run per request like everything else is. So if you need to change + // something in this file, you'll need to manually restart the server. + const queryClient = postgres(DATABASE_URL, { + ssl: process.env.PG_CLIENT_SSL === "true" ? true : false, + }); + const client = drizzle(queryClient, { schema }); - return { drizzle: drizzleDb, pg: rawPg } + return client; } -export { drizzleClient, pg } +export { drizzleClient }; diff --git a/app/lib/api-schemas/boxes-data-query-schema.ts b/app/lib/api-schemas/boxes-data-query-schema.ts deleted file mode 100644 index d946b5c2..00000000 --- a/app/lib/api-schemas/boxes-data-query-schema.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { z } from 'zod' -import { type DeviceExposureType } from '~/schema' -import { StandardResponse } from '~/utils/response-utils' - -export type BoxesDataColumn = - | 'createdAt' - | 'value' - | 'lat' - | 'lon' - | 'height' - | 'boxid' - | 'boxName' - | 'exposure' - | 'sensorId' - | 'phenomenon' - | 'unit' - | 'sensorType' - -const BoxesDataQuerySchemaBase = z - .object({ - phenomenon: z.string().optional(), - - boxid: z - .union([ - z.string().transform((s) => s.split(',').map((x) => x.trim())), - z - .array(z.string()) - .transform((arr) => arr.map((s) => String(s).trim())), - ]) - .optional(), - bbox: z - .union([ - z.string().transform((s) => s.split(',').map((x) => Number(x.trim()))), - z - .array(z.union([z.string(), z.number()])) - .transform((arr) => arr.map((x) => Number(x))), - ]) - .refine((arr) => arr.length === 4 && arr.every((n) => !isNaN(n)), { - message: 'bbox must contain exactly 4 numeric coordinates', - }) - .optional(), - - exposure: z - .union([ - z - .string() - .transform((s) => - s.split(',').map((x) => x.trim() as DeviceExposureType), - ), - z - .array(z.string()) - .transform((arr) => - arr.map((s) => String(s).trim() as DeviceExposureType), - ), - ]) - .optional(), - - grouptag: z.string().optional(), - - fromDate: z - .string() - .transform((s) => new Date(s)) - .refine((d) => !isNaN(d.getTime()), { - message: 'from-date is invalid', - }) - .optional() - .default(() => - new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), - ), - toDate: z - .string() - .transform((s) => new Date(s)) - .refine((d) => !isNaN(d.getTime()), { - message: 'to-date is invalid', - }) - .optional() - .default(() => new Date().toISOString()), - - format: z - .enum(['csv', 'json'], { - errorMap: () => ({ message: "Format must be either 'csv' or 'json'" }), - }) - .default('csv'), - - // Columns to include - columns: z - .union([ - z - .string() - .transform((s) => - s.split(',').map((x) => x.trim() as BoxesDataColumn), - ), - z - .array(z.string()) - .transform((arr) => - arr.map((s) => String(s).trim() as BoxesDataColumn), - ), - ]) - .default([ - 'sensorId', - 'createdAt', - 'value', - 'lat', - 'lon', - ] as BoxesDataColumn[]), - - download: z - .union([z.string(), z.boolean()]) - .transform((v) => { - if (typeof v === 'boolean') return v - return v !== 'false' && v !== '0' - }) - .default(true), - - delimiter: z.enum(['comma', 'semicolon']).default('comma'), - }) - // Validate: must have boxid or bbox, but not both - .superRefine((data, ctx) => { - if (!data.boxid && !data.bbox && !data.grouptag) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'please specify either boxid, bbox or grouptag', - path: ['boxid'], - }) - } - - if (!data.phenomenon && !data.grouptag) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - 'phenomenon parameter is required when grouptag is not provided', - path: ['phenomenon'], - }) - } - }) - -export type BoxesDataQueryParams = z.infer - -/** - * Parse and validate query parameters from request. - * Supports both GET query params and POST JSON body. - */ -export async function parseBoxesDataQuery( - request: Request, -): Promise { - const url = new URL(request.url) - let params: Record - if (request.method !== 'GET') { - const contentType = request.headers.get('content-type') || '' - if (contentType.includes('application/json')) { - try { - params = await request.json() - } catch { - params = Object.fromEntries(url.searchParams) - } - } else { - params = Object.fromEntries(url.searchParams) - } - } else { - params = Object.fromEntries(url.searchParams) - } - - const parseResult = BoxesDataQuerySchemaBase.safeParse(params) - - if (!parseResult.success) { - const firstError = parseResult.error.errors[0] - const message = firstError.message || 'Invalid query parameters' - - if (firstError.path.includes('bbox')) { - throw StandardResponse.unprocessableContent(message) - } - throw StandardResponse.badRequest(message) - } - - return parseResult.data -} diff --git a/app/lib/device-transform.ts b/app/lib/device-transform.ts index 88d3257b..0937c223 100644 --- a/app/lib/device-transform.ts +++ b/app/lib/device-transform.ts @@ -1,107 +1,102 @@ -import { type Device, type Sensor } from '~/schema' +import { type Device, type Sensor } from '~/schema'; export type DeviceWithSensors = Device & { - sensors: Sensor[] -} + sensors: Sensor[]; +}; export type TransformedDevice = { - _id: string - name: string - description: string | null - image: string | null - link: string | null - grouptag: string[] - exposure: string | null - model: string | null - latitude: number - longitude: number - useAuth: boolean | null - public: boolean | null - status: string | null - createdAt: Date - updatedAt: Date - expiresAt: Date | null - userId: string - sensorWikiModel?: string | null - currentLocation: { - type: 'Point' - coordinates: number[] - timestamp: string - } - lastMeasurementAt: string - loc: Array<{ - type: 'Feature' - geometry: { - type: 'Point' - coordinates: number[] - timestamp: string - } - }> - integrations: { - mqtt: { - enabled: boolean - } - } - sensors: Array<{ - _id: string - title: string | null - unit: string | null - sensorType: string | null - lastMeasurement: { - value: string - createdAt: string - } | null - }> -} + _id: string; + name: string; + description: string | null; + image: string | null; + link: string | null; + grouptag: string[]; + exposure: string | null; + model: string | null; + latitude: number; + longitude: number; + useAuth: boolean | null; + public: boolean | null; + status: string | null; + createdAt: Date; + updatedAt: Date; + expiresAt: Date | null; + userId: string; + sensorWikiModel?: string | null; + currentLocation: { + type: "Point"; + coordinates: number[]; + timestamp: string; + }; + lastMeasurementAt: string; + loc: Array<{ + type: "Feature"; + geometry: { + type: "Point"; + coordinates: number[]; + timestamp: string; + }; + }>; + integrations: { + mqtt: { + enabled: boolean; + }; + }; + sensors: Array<{ + _id: string; + title: string | null; + unit: string | null; + sensorType: string | null; + lastMeasurement: { + value: string; + createdAt: string; + } | null; + }>; +}; /** * Transforms a device with sensors from database format to openSenseMap API format * @param box - Device object with sensors from database * @returns Transformed device in openSenseMap API format - * + * * Note: Converts lastMeasurement.value from number to string to match API specification */ export function transformDeviceToApiFormat( - box: DeviceWithSensors, + box: DeviceWithSensors ): TransformedDevice { - const { id, tags, sensors, ...rest } = box - const timestamp = box.updatedAt.toISOString() - const coordinates = [box.longitude, box.latitude] - - return { - _id: id, - grouptag: tags || [], - ...rest, - currentLocation: { - type: 'Point', - coordinates, - timestamp, - }, - lastMeasurementAt: timestamp, - loc: [ - { - geometry: { type: 'Point', coordinates, timestamp }, - type: 'Feature', - }, - ], - integrations: { mqtt: { enabled: false } }, - sensors: - sensors?.map((sensor) => ({ - _id: sensor.id, - title: sensor.title, - unit: sensor.unit, - sensorType: sensor.sensorType, - icon: sensor.icon, - lastMeasurement: sensor.lastMeasurement - ? { - createdAt: sensor.lastMeasurement.createdAt, - // Convert number to string to match API specification - value: - typeof sensor.lastMeasurement.value === 'number' - ? String(sensor.lastMeasurement.value) - : sensor.lastMeasurement.value, - } - : null, - })) || [], - } + const { id, tags, sensors, ...rest } = box; + const timestamp = box.updatedAt.toISOString(); + const coordinates = [box.longitude, box.latitude]; + + return { + _id: id, + grouptag: tags || [], + ...rest, + currentLocation: { + type: "Point", + coordinates, + timestamp + }, + lastMeasurementAt: timestamp, + loc: [{ + geometry: { type: "Point", coordinates, timestamp }, + type: "Feature" + }], + integrations: { mqtt: { enabled: false } }, + sensors: sensors?.map((sensor) => ({ + _id: sensor.id, + title: sensor.title, + unit: sensor.unit, + sensorType: sensor.sensorType, + lastMeasurement: sensor.lastMeasurement + ? { + createdAt: sensor.lastMeasurement.createdAt, + // Convert numeric values to string to match API specification + value: typeof sensor.lastMeasurement.value === 'number' + ? String(sensor.lastMeasurement.value) + : sensor.lastMeasurement.value, + } + : null, + })) || [], + }; } diff --git a/app/lib/measurement-service.server.ts b/app/lib/measurement-service.server.ts index 6fc911be..6f1a6468 100644 --- a/app/lib/measurement-service.server.ts +++ b/app/lib/measurement-service.server.ts @@ -1,4 +1,3 @@ -import { type BoxesDataColumn } from './api-schemas/boxes-data-query-schema' import { validLngLat } from './location' import { decodeMeasurements, hasDecoder } from '~/lib/decoding-service.server' import { @@ -252,72 +251,3 @@ export const postSingleMeasurement = async ( throw error } } - -/** - * Transform a measurement row into an object with requested columns. - * - prefer measurement location if present - * - otherwise fall back to sensor/device location (sensorsMap) - */ -export function transformMeasurement( - m: { - sensorId: string - createdAt: Date | null - value: number | null - locationId: bigint | null - }, - sensorsMap: Record, - locationsMap: Record, - columns: BoxesDataColumn[], -) { - const sensor = sensorsMap[m.sensorId] - const measurementLocation = m.locationId - ? locationsMap[m.locationId.toString()] - : null - - const result: Record = {} - - for (const col of columns) { - switch (col) { - case 'createdAt': - result.createdAt = m.createdAt ? m.createdAt.toISOString() : null - break - case 'value': - result.value = m.value - break - case 'lat': - result.lat = measurementLocation?.lat ?? sensor?.lat - break - case 'lon': - result.lon = measurementLocation?.lon ?? sensor?.lon - break - case 'height': - result.height = measurementLocation?.height ?? sensor?.height ?? null - break - case 'boxid': - result.boxid = sensor?.boxid - break - case 'boxName': - result.boxName = sensor?.boxName - break - case 'exposure': - result.exposure = sensor?.exposure - break - case 'sensorId': - result.sensorId = sensor?.sensorId - break - case 'phenomenon': - result.phenomenon = sensor?.phenomenon - break - case 'unit': - result.unit = sensor?.unit - break - case 'sensorType': - result.sensorType = sensor?.sensorType - break - default: - break - } - } - - return result -} diff --git a/app/models/device.server.ts b/app/models/device.server.ts index 4b461eed..ee122780 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -1,839 +1,581 @@ -import { point } from '@turf/helpers' -import { eq, sql, desc, ilike, arrayContains, and, between } from 'drizzle-orm' +import { point } from "@turf/helpers"; +import { eq, sql, desc, ilike, arrayContains, and, between } from "drizzle-orm"; import BaseNewDeviceEmail, { - messages as BaseNewDeviceMessages, -} from 'emails/base-new-device' -import { messages as NewLufdatenDeviceMessages } from 'emails/new-device-luftdaten' -import { messages as NewSenseboxDeviceMessages } from 'emails/new-device-sensebox' -import { type Point } from 'geojson' -import { drizzleClient } from '~/db.server' -import { sendMail } from '~/lib/mail.server' + messages as BaseNewDeviceMessages, +} from "emails/base-new-device"; +import { messages as NewLufdatenDeviceMessages } from "emails/new-device-luftdaten"; +import { messages as NewSenseboxDeviceMessages } from "emails/new-device-sensebox"; +import { type Point } from "geojson"; +import { drizzleClient } from "~/db.server"; +import { sendMail } from "~/lib/mail.server"; import { - device, - deviceToLocation, - location, - sensor, - user, - type Device, - type Sensor, -} from '~/schema' -import { getSensorsForModel } from '~/utils/model-definitions' + device, + deviceToLocation, + location, + sensor, + user, + type Device, + type Sensor, +} from "~/schema"; const BASE_DEVICE_COLUMNS = { - id: true, - name: true, - description: true, - image: true, - link: true, - tags: true, - exposure: true, - model: true, - latitude: true, - longitude: true, - status: true, - createdAt: true, - updatedAt: true, - expiresAt: true, - useAuth: true, - sensorWikiModel: true, -} as const + id: true, + name: true, + description: true, + image: true, + link: true, + tags: true, + exposure: true, + model: true, + latitude: true, + longitude: true, + status: true, + createdAt: true, + updatedAt: true, + expiresAt: true, + useAuth: true, + sensorWikiModel: true, +} as const; const DEVICE_COLUMNS_WITH_SENSORS = { - ...BASE_DEVICE_COLUMNS, - useAuth: true, - public: true, - userId: true, -} as const - -export class DeviceUpdateError extends Error { - constructor( - message: string, - public statusCode: number = 400, - ) { - super(message) - this.name = 'DeviceUpdateError' - } -} - -export function getDevice({ id }: Pick) { - return drizzleClient.query.device.findFirst({ - where: (device, { eq }) => eq(device.id, id), - columns: BASE_DEVICE_COLUMNS, - with: { - user: { - columns: { - id: true, - }, - }, - logEntries: { - where: (entry, { eq }) => eq(entry.public, true), - columns: { - id: true, - content: true, - createdAt: true, - public: true, - deviceId: true, - }, - }, - locations: { - // https://github.com/drizzle-team/drizzle-orm/pull/2778 - // with: { - // geometry: true - // }, - columns: { - // time: true, - }, - extras: { - time: sql`time`.as('time'), - }, - with: { - geometry: { - columns: {}, - extras: { - x: sql`ST_X(${location.location})`.as('x'), - y: sql`ST_Y(${location.location})`.as('y'), - }, - }, - }, - // limit: 1000, - }, - sensors: true, - }, - }) + ...BASE_DEVICE_COLUMNS, + useAuth: true, + public: true, + userId: true, +} as const; + +export function getDevice({ id }: Pick) { + return drizzleClient.query.device.findFirst({ + where: (device, { eq }) => eq(device.id, id), + columns: BASE_DEVICE_COLUMNS, + with: { + user: { + columns: { + id: true, + }, + }, + logEntries: { + where: (entry, { eq }) => eq(entry.public, true), + columns: { + id: true, + content: true, + createdAt: true, + public: true, + deviceId: true, + }, + }, + locations: { + // https://github.com/drizzle-team/drizzle-orm/pull/2778 + // with: { + // geometry: true + // }, + columns: { + // time: true, + }, + extras: { + time: sql`time`.as("time"), + }, + with: { + geometry: { + columns: {}, + extras: { + x: sql`ST_X(${location.location})`.as("x"), + y: sql`ST_Y(${location.location})`.as("y"), + }, + }, + }, + // limit: 1000, + }, + sensors: true, + }, + }); } export function getLocations( - { id }: Pick, - fromDate: Date, - toDate: Date, + { id }: Pick, + fromDate: Date, + toDate: Date ) { - return drizzleClient - .select({ - time: deviceToLocation.time, - x: sql`ST_X(${location.location})`.as('x'), - y: sql`ST_Y(${location.location})`.as('y'), - }) - .from(location) - .innerJoin(deviceToLocation, eq(deviceToLocation.locationId, location.id)) - .where( - and( - eq(deviceToLocation.deviceId, id), - between(deviceToLocation.time, fromDate, toDate), - ), - ) - .orderBy(desc(deviceToLocation.time)) + return drizzleClient + .select({ + time: deviceToLocation.time, + x: sql`ST_X(${location.location})`.as("x"), + y: sql`ST_Y(${location.location})`.as("y"), + }) + .from(location) + .innerJoin(deviceToLocation, eq(deviceToLocation.locationId, location.id)) + .where( + and( + eq(deviceToLocation.deviceId, id), + between(deviceToLocation.time, fromDate, toDate) + ) + ) + .orderBy(desc(deviceToLocation.time)); } -export function getDeviceWithoutSensors({ id }: Pick) { - return drizzleClient.query.device.findFirst({ - where: (device, { eq }) => eq(device.id, id), - columns: { - id: true, - name: true, - exposure: true, - updatedAt: true, - latitude: true, - longitude: true, - }, - }) +export function getDeviceWithoutSensors({ id }: Pick) { + return drizzleClient.query.device.findFirst({ + where: (device, { eq }) => eq(device.id, id), + columns: { + id: true, + name: true, + exposure: true, + updatedAt: true, + latitude: true, + longitude: true, + }, + }); } export type DeviceWithoutSensors = Awaited< - ReturnType -> - -export function updateDeviceLocation({ - id, - latitude, - longitude, -}: Pick) { - return drizzleClient - .update(device) - .set({ latitude: latitude, longitude: longitude }) - .where(eq(device.id, id)) -} - -export type UpdateDeviceArgs = { - name?: string - exposure?: string - grouptag?: string | string[] - description?: string - link?: string - image?: string - model?: string - useAuth?: boolean - location?: { lat: number; lng: number; height?: number } - sensors?: SensorUpdateArgs[] + ReturnType +>; + +export function updateDeviceInfo({ + id, + name, + exposure, +}: Pick) { + return drizzleClient + .update(device) + .set({ name: name, exposure: exposure }) + .where(eq(device.id, id)); } -type SensorUpdateArgs = { - _id?: string - title?: string - unit?: string - sensorType?: string - icon?: string - deleted?: any - edited?: any - new?: any -} - -export async function updateDevice( - deviceId: string, - args: UpdateDeviceArgs, -): Promise { - const setColumns: Record = {} - const updatableFields: (keyof UpdateDeviceArgs)[] = [ - 'name', - 'exposure', - 'description', - 'image', - 'model', - 'useAuth', - 'link', - ] - - for (const field of updatableFields) { - if (args[field] !== undefined) { - // Handle empty string -> null for specific fields (backwards compatibility) - if ( - (field === 'description' || field === 'link' || field === 'image') && - args[field] === '' - ) { - setColumns[field] = null - } else { - setColumns[field] = args[field] - } - } - } - - if ('grouptag' in args) { - if (Array.isArray(args.grouptag)) { - // Empty array -> null for backwards compatibility - setColumns['tags'] = args.grouptag.length === 0 ? null : args.grouptag - } else if (args.grouptag != null) { - // Empty string -> null - setColumns['tags'] = args.grouptag === '' ? null : [args.grouptag] - } else { - setColumns['tags'] = null - } - } - - const result = await drizzleClient.transaction(async (tx) => { - if (args.location) { - const { lat, lng, height } = args.location - - const pointWKT = `POINT(${lng} ${lat})` - - const [existingLocation] = await tx - .select() - .from(location) - .where(sql`ST_Equals(location, ST_GeomFromText(${pointWKT}, 4326))`) - .limit(1) - - let locationId: bigint - - if (existingLocation) { - locationId = existingLocation.id - } else { - const [newLocation] = await tx - .insert(location) - .values({ - location: sql`ST_GeomFromText(${pointWKT}, 4326)`, - }) - .returning() - - if (!newLocation) { - throw new Error('Failed to create location') - } - - locationId = newLocation.id - } - - await tx - .insert(deviceToLocation) - .values({ - deviceId, - locationId, - time: sql`NOW()`, - }) - .onConflictDoNothing() - - setColumns['latitude'] = lat - setColumns['longitude'] = lng - } - - let updatedDevice - if (Object.keys(setColumns).length > 0) { - ;[updatedDevice] = await tx - .update(device) - .set({ ...setColumns, updatedAt: sql`NOW()` }) - .where(eq(device.id, deviceId)) - .returning() - - if (!updatedDevice) { - throw new DeviceUpdateError(`Device ${deviceId} not found`, 404) - } - } else { - ;[updatedDevice] = await tx - .select() - .from(device) - .where(eq(device.id, deviceId)) - - if (!updatedDevice) { - throw new DeviceUpdateError(`Device ${deviceId} not found`, 404) - } - } - - if (args.sensors?.length) { - const existingSensors = await tx - .select() - .from(sensor) - .where(eq(sensor.deviceId, deviceId)) - - const sensorsToDelete = args.sensors.filter( - (s) => 'deleted' in s && s._id, - ) - const remainingSensorCount = - existingSensors.length - sensorsToDelete.length - - if (sensorsToDelete.length > 0 && remainingSensorCount < 1) { - throw new DeviceUpdateError( - 'Unable to delete sensor(s). A box needs at least one sensor.', - ) - } - - for (const s of args.sensors) { - const hasDeleted = 'deleted' in s - const hasEdited = 'edited' in s - const hasNew = 'new' in s - - if (!hasDeleted && !hasEdited && !hasNew) { - continue - } - - if (hasDeleted) { - if (!s._id) { - throw new DeviceUpdateError('Sensor deletion requires _id') - } - - const sensorExists = existingSensors.some( - (existing) => existing.id === s._id, - ) - - if (!sensorExists) { - throw new DeviceUpdateError( - `Sensor with id ${s._id} not found for deletion.`, - ) - } - - await tx.delete(sensor).where(eq(sensor.id, s._id)) - } else if (hasEdited && hasNew) { - if (!s.title || !s.unit || !s.sensorType) { - throw new DeviceUpdateError( - 'New sensor requires title, unit, and sensorType', - ) - } - - await tx.insert(sensor).values({ - title: s.title, - unit: s.unit, - sensorType: s.sensorType, - icon: s.icon, - deviceId, - }) - } else if (hasEdited && s._id) { - const sensorExists = existingSensors.some( - (existing) => existing.id === s._id, - ) - - if (!sensorExists) { - throw new DeviceUpdateError( - `Sensor with id ${s._id} not found for editing.`, - ) - } - - if (!s.title || !s.unit || !s.sensorType) { - throw new DeviceUpdateError( - 'Editing sensor requires all properties: _id, title, unit, sensorType, icon', - ) - } - - await tx - .update(sensor) - .set({ - title: s.title, - unit: s.unit, - sensorType: s.sensorType, - icon: s.icon, - updatedAt: sql`NOW()`, - }) - .where(eq(sensor.id, s._id)) - } - } - } - return updatedDevice - }) - - return result +export function updateDeviceLocation({ + id, + latitude, + longitude, +}: Pick) { + return drizzleClient + .update(device) + .set({ latitude: latitude, longitude: longitude }) + .where(eq(device.id, id)); } -export function deleteDevice({ id }: Pick) { - return drizzleClient.delete(device).where(eq(device.id, id)) +export function deleteDevice({ id }: Pick) { + return drizzleClient.delete(device).where(eq(device.id, id)); } -export function getUserDevices(userId: Device['userId']) { - return drizzleClient.query.device.findMany({ - where: (device, { eq }) => eq(device.userId, userId), - columns: DEVICE_COLUMNS_WITH_SENSORS, - with: { - sensors: true, - }, - }) +export function getUserDevices(userId: Device["userId"]) { + return drizzleClient.query.device.findMany({ + where: (device, { eq }) => eq(device.userId, userId), + columns: DEVICE_COLUMNS_WITH_SENSORS, + with: { + sensors: true, + }, + }); } -type DevicesFormat = 'json' | 'geojson' +type DevicesFormat = "json" | "geojson"; -export async function getDevices(format: 'json'): Promise +export async function getDevices(format: "json"): Promise; export async function getDevices( - format: 'geojson', -): Promise> + format: "geojson" +): Promise>; export async function getDevices( - format?: DevicesFormat, -): Promise> - -export async function getDevices(format: DevicesFormat = 'json') { - const devices = await drizzleClient.query.device.findMany({ - columns: { - id: true, - name: true, - latitude: true, - longitude: true, - exposure: true, - status: true, - createdAt: true, - tags: true, - }, - }) - - if (format === 'geojson') { - const geojson: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: [], - } - - for (const device of devices) { - const coordinates = [device.longitude, device.latitude] - const feature = point(coordinates, device) - geojson.features.push(feature) - } - - return geojson - } - - return devices + format?: DevicesFormat +): Promise>; + +export async function getDevices(format: DevicesFormat = "json") { + const devices = await drizzleClient.query.device.findMany({ + columns: { + id: true, + name: true, + latitude: true, + longitude: true, + exposure: true, + status: true, + createdAt: true, + tags: true, + }, + }); + + if (format === "geojson") { + const geojson: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: [], + }; + + for (const device of devices) { + const coordinates = [device.longitude, device.latitude]; + const feature = point(coordinates, device); + geojson.features.push(feature); + } + + return geojson; + } + + return devices; } export async function getDevicesWithSensors() { - const rows = await drizzleClient - .select({ - device: device, - sensor: { - id: sensor.id, - title: sensor.title, - sensorWikiPhenomenon: sensor.sensorWikiPhenomenon, - lastMeasurement: sensor.lastMeasurement, - }, - }) - .from(device) - .leftJoin(sensor, eq(sensor.deviceId, device.id)) - const geojson: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: [], - } - - type PartialSensor = Pick< - Sensor, - 'id' | 'title' | 'sensorWikiPhenomenon' | 'lastMeasurement' - > - const deviceMap = new Map< - string, - { device: Device & { sensors: PartialSensor[] } } - >() - - const resultArray: Array<{ device: Device & { sensors: PartialSensor[] } }> = - rows.reduce( - (acc, row) => { - const device = row.device - const sensor = row.sensor - - if (!deviceMap.has(device.id)) { - const newDevice = { - device: { ...device, sensors: sensor ? [sensor] : [] }, - } - deviceMap.set(device.id, newDevice) - acc.push(newDevice) - } else if (sensor) { - deviceMap.get(device.id)!.device.sensors.push(sensor) - } - - return acc - }, - [] as Array<{ device: Device & { sensors: PartialSensor[] } }>, - ) - - for (const device of resultArray) { - const coordinates = [device.device.longitude, device.device.latitude] - const feature = point(coordinates, device.device) - geojson.features.push(feature) - } - - return geojson + const rows = await drizzleClient + .select({ + device: device, + sensor: { + id: sensor.id, + title: sensor.title, + sensorWikiPhenomenon: sensor.sensorWikiPhenomenon, + lastMeasurement: sensor.lastMeasurement, + }, + }) + .from(device) + .leftJoin(sensor, eq(sensor.deviceId, device.id)); + const geojson: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: [], + }; + + type PartialSensor = Pick< + Sensor, + "id" | "title" | "sensorWikiPhenomenon" | "lastMeasurement" + >; + const deviceMap = new Map< + string, + { device: Device & { sensors: PartialSensor[] } } + >(); + + const resultArray: Array<{ device: Device & { sensors: PartialSensor[] } }> = + rows.reduce( + (acc, row) => { + const device = row.device; + const sensor = row.sensor; + + if (!deviceMap.has(device.id)) { + const newDevice = { + device: { ...device, sensors: sensor ? [sensor] : [] }, + }; + deviceMap.set(device.id, newDevice); + acc.push(newDevice); + } else if (sensor) { + deviceMap.get(device.id)!.device.sensors.push(sensor); + } + + return acc; + }, + [] as Array<{ device: Device & { sensors: PartialSensor[] } }> + ); + + for (const device of resultArray) { + const coordinates = [device.device.longitude, device.device.latitude]; + const feature = point(coordinates, device.device); + geojson.features.push(feature); + } + + return geojson; } interface BuildWhereClauseOptions { - name?: string - phenomenon?: string - fromDate?: string | Date - toDate?: string | Date - bbox?: { - coordinates: (number | undefined)[][][] - } - near?: [number, number] // [lat, lng] - maxDistance?: number - grouptag?: string[] - exposure?: string[] - model?: string[] + name?: string; + phenomenon?: string; + fromDate?: string | Date; + toDate?: string | Date; + bbox?: { + coordinates: number[][][]; + }; + near?: [number, number]; // [lat, lng] + maxDistance?: number; + grouptag?: string[]; + exposure?: string[]; + model?: string[]; } export interface FindDevicesOptions extends BuildWhereClauseOptions { - minimal?: string | boolean - limit?: number - format?: 'json' | 'geojson' + minimal?: string | boolean; + limit?: number; + format?: "json" | "geojson"; } interface WhereClauseResult { - includeColumns: Record - whereClause: any[] + includeColumns: Record; + whereClause: any[]; } const buildWhereClause = function buildWhereClause( - opts: BuildWhereClauseOptions = {}, + opts: BuildWhereClauseOptions = {} ): WhereClauseResult { - const { - name, - phenomenon, - fromDate, - toDate, - bbox, - near, - maxDistance, - grouptag, - } = opts - const clause = [] - const columns = {} - - if (name) { - clause.push(ilike(device.name, `%${name}%`)) - } - - if (phenomenon) { - // @ts-ignore - columns['sensors'] = { - // @ts-ignore - where: (sensor, { ilike }) => - // @ts-ignore - ilike(sensorTable['title'], `%${phenomenon}%`), - } - } - - // simple string parameters - // for (const param of ['exposure', 'model'] as const) { - // if (opts[param]) { - // clause.push(inArray(device[param], opts[param]!)); - // } - // } - - if (grouptag) { - clause.push(arrayContains(device.tags, grouptag)) - } - - // https://orm.drizzle.team/learn/guides/postgis-geometry-point - if (bbox && bbox.coordinates[0]) { - const [latSW, lngSW] = bbox.coordinates[0][0] - const [latNE, lngNE] = bbox.coordinates[0][2] - clause.push( - sql`ST_Contains( + const { + name, + phenomenon, + fromDate, + toDate, + bbox, + near, + maxDistance, + grouptag, + } = opts; + const clause = []; + const columns = {}; + + if (name) { + clause.push(ilike(device.name, `%${name}%`)); + } + + if (phenomenon) { + // @ts-ignore + columns["sensors"] = { + // @ts-ignore + where: (sensor, { ilike }) => + // @ts-ignore + ilike(sensorTable["title"], `%${phenomenon}%`), + }; + } + + // simple string parameters + // for (const param of ['exposure', 'model'] as const) { + // if (opts[param]) { + // clause.push(inArray(device[param], opts[param]!)); + // } + // } + + if (grouptag) { + clause.push(arrayContains(device.tags, grouptag)); + } + + // https://orm.drizzle.team/learn/guides/postgis-geometry-point + if (bbox) { + const [latSW, lngSW] = bbox.coordinates[0][0]; + const [latNE, lngNE] = bbox.coordinates[0][2]; + clause.push( + sql`ST_Contains( ST_MakeEnvelope(${lngSW}, ${latSW}, ${lngNE}, ${latNE}, 4326), ST_SetSRID(ST_MakePoint(${device.longitude}, ${device.latitude}), 4326) - )`, - ) - } + )` + ); + } - if (near && maxDistance !== undefined) { - clause.push( - sql`ST_DWithin( + if (near && maxDistance !== undefined) { + clause.push( + sql`ST_DWithin( ST_SetSRID(ST_MakePoint(${device.longitude}, ${device.latitude}), 4326), ST_SetSRID(ST_MakePoint(${near[1]}, ${near[0]}), 4326), ${maxDistance} - )`, - ) - } - - if (phenomenon && (fromDate || toDate)) { - // @ts-ignore - columns['sensors'] = { - include: { - measurements: { - where: (measurement: any) => { - const conditions = [] - - if (fromDate && toDate) { - conditions.push( - sql`${measurement.createdAt} BETWEEN ${fromDate} AND ${toDate}`, - ) - } else if (fromDate) { - conditions.push(sql`${measurement.createdAt} >= ${fromDate}`) - } else if (toDate) { - conditions.push(sql`${measurement.createdAt} <= ${toDate}`) - } - - return and(...conditions) - }, - }, - }, - } - } - - return { - includeColumns: columns, - whereClause: clause, - } -} + )` + ); + } + + if (phenomenon && (fromDate || toDate)) { + // @ts-ignore + columns["sensors"] = { + include: { + measurements: { + where: (measurement: any) => { + const conditions = []; + + if (fromDate && toDate) { + conditions.push( + sql`${measurement.createdAt} BETWEEN ${fromDate} AND ${toDate}` + ); + } else if (fromDate) { + conditions.push(sql`${measurement.createdAt} >= ${fromDate}`); + } else if (toDate) { + conditions.push(sql`${measurement.createdAt} <= ${toDate}`); + } + + return and(...conditions); + }, + }, + }, + }; + } + + return { + includeColumns: columns, + whereClause: clause, + }; +}; const MINIMAL_COLUMNS = { - id: true, - name: true, - exposure: true, - longitude: true, - latitude: true, -} + id: true, + name: true, + exposure: true, + longitude: true, + latitude: true, +}; const DEFAULT_COLUMNS = { - id: true, - name: true, - model: true, - exposure: true, - grouptag: true, - image: true, - description: true, - link: true, - createdAt: true, - updatedAt: true, - longitude: true, - latitude: true, -} + id: true, + name: true, + model: true, + exposure: true, + grouptag: true, + image: true, + description: true, + link: true, + createdAt: true, + updatedAt: true, + longitude: true, + latitude: true, +}; export async function findDevices( - opts: FindDevicesOptions = {}, - columns: Record = {}, - relations: Record = {}, + opts: FindDevicesOptions = {}, + columns: Record = {}, + relations: Record = {} ) { - const { minimal, limit } = opts - const { includeColumns, whereClause } = buildWhereClause(opts) - columns = minimal ? MINIMAL_COLUMNS : { ...DEFAULT_COLUMNS, ...columns } - relations = { - ...relations, - ...includeColumns, - } - const devices = await drizzleClient.query.device.findMany({ - ...(Object.keys(columns).length !== 0 && { columns }), - ...(Object.keys(relations).length !== 0 && { with: relations }), - ...(Object.keys(whereClause).length !== 0 && { - where: (_, { and }) => and(...whereClause), - }), - limit, - }) - - return devices + const { minimal, limit } = opts; + const { includeColumns, whereClause } = buildWhereClause(opts); + columns = minimal ? MINIMAL_COLUMNS : { ...DEFAULT_COLUMNS, ...columns }; + relations = { + ...relations, + ...includeColumns, + }; + const devices = await drizzleClient.query.device.findMany({ + ...(Object.keys(columns).length !== 0 && { columns }), + ...(Object.keys(relations).length !== 0 && { with: relations }), + ...(Object.keys(whereClause).length !== 0 && { + where: (_, { and }) => and(...whereClause), + }), + limit, + }); + + return devices; } export async function createDevice(deviceData: any, userId: string) { - try { - const [newDevice, usr] = await drizzleClient.transaction(async (tx) => { - // Get the user info - const [u] = await tx - .select() - .from(user) - .where(eq(user.id, userId)) - .limit(1) - - // Determine sensors to use - let sensorsToAdd = deviceData.sensors - - // If model and sensors are both specified, reject (backwards compatibility) - if (deviceData.model && deviceData.sensors) { - throw new Error( - 'Parameters model and sensors cannot be specified at the same time.', - ) - } - - // If model is specified but sensors are not, get sensors from model layout - if (deviceData.model && !deviceData.sensors) { - const modelSensors = getSensorsForModel(deviceData.model as any) - if (modelSensors) { - sensorsToAdd = modelSensors - } - } - - // Create the device - const [createdDevice] = await tx - .insert(device) - .values({ - id: deviceData.id, - useAuth: deviceData.useAuth ?? true, - model: deviceData.model, - tags: deviceData.tags, - userId: userId, - name: deviceData.name, - description: deviceData.description, - image: deviceData.image, - link: deviceData.link, - exposure: deviceData.exposure, - public: deviceData.public ?? false, - expiresAt: deviceData.expiresAt - ? new Date(deviceData.expiresAt) - : null, - latitude: deviceData.latitude, - longitude: deviceData.longitude, - }) - .returning() - - if (!createdDevice) { - throw new Error('Failed to create device.') - } - - // Add sensors in the same transaction and collect them - const createdSensors = [] - if ( - sensorsToAdd && - Array.isArray(sensorsToAdd) && - sensorsToAdd.length > 0 - ) { - for (const sensorData of sensorsToAdd) { - const [newSensor] = await tx - .insert(sensor) - .values({ - title: sensorData.title, - unit: sensorData.unit, - sensorType: sensorData.sensorType, - icon: sensorData.icon, - deviceId: createdDevice.id, - }) - .returning() - - if (newSensor) { - createdSensors.push(newSensor) - } - } - } - - // Return device with sensors - return [ - { - ...createdDevice, - sensors: createdSensors, - }, - u, - ] - }) - - const lng = (usr.language?.split('_')[0] as 'de' | 'en') ?? 'en' - switch (newDevice.model) { - case 'luftdaten.info': - case 'luftdaten_sds011': - case 'luftdaten_sds011_bme280': - case 'luftdaten_sds011_bmp180': - case 'luftdaten_sds011_dht11': - case 'luftdaten_sds011_dht22': - await sendMail({ - recipientAddress: usr.email, - recipientName: usr.name, - subject: NewLufdatenDeviceMessages[lng].heading, - body: BaseNewDeviceEmail({ - user: { name: usr.name }, - device: newDevice, - language: lng, - content: NewLufdatenDeviceMessages, - }), - }) - break - case 'homeV2Ethernet': - case 'homeV2Lora': - case 'homeV2Wifi': - case 'homeEthernet': - case 'homeEthernetFeinstaub': - case 'homeWifi': - case 'homeWifiFeinstaub': - case 'senseBox:Edu': - await sendMail({ - recipientAddress: usr.email, - recipientName: usr.name, - subject: NewSenseboxDeviceMessages[lng].heading, - body: BaseNewDeviceEmail({ - user: { name: usr.name }, - device: newDevice, - language: lng, - content: NewSenseboxDeviceMessages, - }), - }) - break - default: - await sendMail({ - recipientAddress: usr.email, - recipientName: usr.name, - subject: BaseNewDeviceMessages[lng].heading, - body: BaseNewDeviceEmail({ - user: { name: usr.name }, - device: newDevice, - language: lng, - content: BaseNewDeviceMessages, - }), - }) - break - } - - return newDevice - } catch (error) { - console.error('Error creating device with sensors:', error) - throw new Error( - `Failed to create device and its sensors: ${error instanceof Error ? error.message : String(error)}`, - ) - } + try { + const [newDevice, usr] = await drizzleClient.transaction(async (tx) => { + // Get the user info + const [u] = await tx + .select() + .from(user) + .where(eq(user.id, userId)) + .limit(1); + + // Create the device + const [createdDevice] = await tx + .insert(device) + .values({ + id: deviceData.id, + useAuth: deviceData.useAuth ?? true, + model: deviceData.model, + tags: deviceData.tags, + userId: userId, + name: deviceData.name, + description: deviceData.description, + image: deviceData.image, + link: deviceData.link, + exposure: deviceData.exposure, + public: deviceData.public ?? false, + expiresAt: deviceData.expiresAt + ? new Date(deviceData.expiresAt) + : null, + latitude: deviceData.latitude, + longitude: deviceData.longitude, + }) + .returning(); + + if (!createdDevice) { + throw new Error("Failed to create device."); + } + + // Add sensors in the same transaction and collect them + const createdSensors = []; + if (deviceData.sensors && Array.isArray(deviceData.sensors)) { + for (const sensorData of deviceData.sensors) { + const [newSensor] = await tx + .insert(sensor) + .values({ + title: sensorData.title, + unit: sensorData.unit, + sensorType: sensorData.sensorType, + deviceId: createdDevice.id, // Reference the created device ID + }) + .returning(); + + if (newSensor) { + createdSensors.push(newSensor); + } + } + } + + // Return device with sensors + return [ + { + ...createdDevice, + sensors: createdSensors, + }, + u, + ]; + }); + + const lng = (usr.language?.split("_")[0] as "de" | "en") ?? "en"; + switch (newDevice.model) { + case "luftdaten.info": + await sendMail({ + recipientAddress: usr.email, + recipientName: usr.name, + subject: NewLufdatenDeviceMessages[lng].heading, + body: BaseNewDeviceEmail({ + user: { name: usr.name }, + device: newDevice, + language: lng, + content: NewLufdatenDeviceMessages, + }), + }); + break; + case "homeV2Ethernet": + case "homeV2Lora": + case "homeV2Wifi": + case "senseBox:Edu": + await sendMail({ + recipientAddress: usr.email, + recipientName: usr.name, + subject: NewSenseboxDeviceMessages[lng].heading, + body: BaseNewDeviceEmail({ + user: { name: usr.name }, + device: newDevice, + language: lng, + content: NewSenseboxDeviceMessages, + }), + }); + break; + default: + await sendMail({ + recipientAddress: usr.email, + recipientName: usr.name, + subject: BaseNewDeviceMessages[lng].heading, + body: BaseNewDeviceEmail({ + user: { name: usr.name }, + device: newDevice, + language: lng, + content: BaseNewDeviceMessages, + }), + }); + break; + } + + return newDevice; + } catch (error) { + console.error("Error creating device with sensors:", error); + throw new Error("Failed to create device and its sensors."); + } } // get the 10 latest created (createdAt property) devices with id, name, latitude, and longitude export async function getLatestDevices() { - const devices = await drizzleClient - .select({ - id: device.id, - name: device.name, - latitude: device.latitude, - longitude: device.longitude, - }) - .from(device) - .orderBy(desc(device.createdAt)) - .limit(10) - - return devices + const devices = await drizzleClient + .select({ + id: device.id, + name: device.name, + latitude: device.latitude, + longitude: device.longitude, + }) + .from(device) + .orderBy(desc(device.createdAt)) + .limit(10); + + return devices; } export async function findAccessToken( - deviceId: string, + deviceId: string ): Promise<{ token: string } | null> { - const result = await drizzleClient.query.accessToken.findFirst({ - where: (token, { eq }) => eq(token.deviceId, deviceId), - }) + const result = await drizzleClient.query.accessToken.findFirst({ + where: (token, { eq }) => eq(token.deviceId, deviceId), + }); - if (!result || !result.token) return null + if (!result || !result.token) return null; - return { token: result.token } + return { token: result.token }; } diff --git a/app/models/measurement.query.server.ts b/app/models/measurement.query.server.ts deleted file mode 100644 index d91f194b..00000000 --- a/app/models/measurement.query.server.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { and, sql, eq, desc, gte, lte } from 'drizzle-orm' -import { drizzleClient } from '~/db.server' -import { - measurement, - location, - measurements10minView, - measurements1dayView, - measurements1hourView, - measurements1monthView, - measurements1yearView, -} from '~/schema' - -// This function retrieves measurements from the database based on the provided parameters. -export function getMeasurement( - sensorId: string, - aggregation: string, - startDate?: Date, - endDate?: Date, -) { - // If both start date and end date are provided, filter measurements within the specified time range. - if (startDate && endDate) { - // Check the aggregation level for measurements and fetch accordingly. - if (aggregation === '10m') { - return drizzleClient - .select() - .from(measurements10minView) - .where( - and( - eq(measurements10minView.sensorId, sensorId), - gte(measurements10minView.time, startDate), - lte(measurements10minView.time, endDate), - ), - ) - .orderBy(desc(measurements10minView.time)) - } else if (aggregation === '1h') { - return drizzleClient - .select() - .from(measurements1hourView) - .where( - and( - eq(measurements1hourView.sensorId, sensorId), - gte(measurements1hourView.time, startDate), - lte(measurements1hourView.time, endDate), - ), - ) - .orderBy(desc(measurements1hourView.time)) - } else if (aggregation === '1d') { - return drizzleClient - .select() - .from(measurements1dayView) - .where( - and( - eq(measurements1dayView.sensorId, sensorId), - gte(measurements1dayView.time, startDate), - lte(measurements1dayView.time, endDate), - ), - ) - .orderBy(desc(measurements1dayView.time)) - } else if (aggregation === '1m') { - return drizzleClient - .select() - .from(measurements1monthView) - .where( - and( - eq(measurements1monthView.sensorId, sensorId), - gte(measurements1monthView.time, startDate), - lte(measurements1monthView.time, endDate), - ), - ) - .orderBy(desc(measurements1monthView.time)) - } else if (aggregation === '1y') { - return drizzleClient - .select() - .from(measurements1yearView) - .where( - and( - eq(measurements1yearView.sensorId, sensorId), - gte(measurements1yearView.time, startDate), - lte(measurements1yearView.time, endDate), - ), - ) - .orderBy(desc(measurements1yearView.time)) - } - // If aggregation is not specified or different from "15m" and "1d", fetch default measurements. - return drizzleClient.query.measurement.findMany({ - where: (measurement, { eq, gte, lte }) => - and( - eq(measurement.sensorId, sensorId), - gte(measurement.time, startDate), - lte(measurement.time, endDate), - ), - orderBy: [desc(measurement.time)], - with: { - location: { - // https://github.com/drizzle-team/drizzle-orm/pull/2778 - // with: { - // geometry: true - // }, - columns: { - id: true, - }, - extras: { - x: sql`ST_X(${location.location})`.as('x'), - y: sql`ST_Y(${location.location})`.as('y'), - }, - }, - }, - }) - } - - // If only aggregation is provided, fetch measurements without considering time range. - if (aggregation === '10m') { - return drizzleClient - .select() - .from(measurements10minView) - .where(eq(measurements10minView.sensorId, sensorId)) - .orderBy(desc(measurements10minView.time)) - } else if (aggregation === '1h') { - return drizzleClient - .select() - .from(measurements1hourView) - .where(eq(measurements1hourView.sensorId, sensorId)) - .orderBy(desc(measurements1hourView.time)) - } else if (aggregation === '1d') { - return drizzleClient - .select() - .from(measurements1dayView) - .where(eq(measurements1dayView.sensorId, sensorId)) - .orderBy(desc(measurements1dayView.time)) - } else if (aggregation === '1m') { - return drizzleClient - .select() - .from(measurements1monthView) - .where(eq(measurements1monthView.sensorId, sensorId)) - .orderBy(desc(measurements1monthView.time)) - } else if (aggregation === '1y') { - return drizzleClient - .select() - .from(measurements1yearView) - .where(eq(measurements1yearView.sensorId, sensorId)) - .orderBy(desc(measurements1yearView.time)) - } - - // If neither start date nor aggregation are specified, fetch default measurements with a limit of 20000. - return drizzleClient.query.measurement.findMany({ - where: (measurement, { eq }) => eq(measurement.sensorId, sensorId), - orderBy: [desc(measurement.time)], - with: { - location: { - // https://github.com/drizzle-team/drizzle-orm/pull/2778 - // with: { - // geometry: true - // }, - columns: { - id: true, - }, - extras: { - x: sql`ST_X(${location.location})`.as('x'), - y: sql`ST_Y(${location.location})`.as('y'), - }, - }, - }, - limit: 3600, // 60 measurements per hour * 24 hours * 2.5 days - }) -} diff --git a/app/models/measurement.server.ts b/app/models/measurement.server.ts index 3cca9bc0..5aa9ac9e 100644 --- a/app/models/measurement.server.ts +++ b/app/models/measurement.server.ts @@ -1,15 +1,15 @@ -import { and, desc, eq, gte, lte, sql } from 'drizzle-orm' -import { drizzleClient } from '~/db.server' +import { and, desc, eq, gte, lte, sql } from "drizzle-orm"; +import { drizzleClient } from "~/db.server"; import { - type LastMeasurement, - location, - measurement, - measurements10minView, - measurements1dayView, - measurements1hourView, - measurements1monthView, - measurements1yearView, -} from '~/schema' + type LastMeasurement, + location, + measurement, + measurements10minView, + measurements1dayView, + measurements1hourView, + measurements1monthView, + measurements1yearView, +} from "~/schema"; import { type MinimalDevice, type MeasurementWithLocation, @@ -22,242 +22,240 @@ import { // This function retrieves measurements from the database based on the provided parameters. export function getMeasurement( - sensorId: string, - aggregation: string, - startDate?: Date, - endDate?: Date, + sensorId: string, + aggregation: string, + startDate?: Date, + endDate?: Date, ) { - // If both start date and end date are provided, filter measurements within the specified time range. - if (startDate && endDate) { - // Check the aggregation level for measurements and fetch accordingly. - if (aggregation === '10m') { - return drizzleClient - .select() - .from(measurements10minView) - .where( - and( - eq(measurements10minView.sensorId, sensorId), - gte(measurements10minView.time, startDate), - lte(measurements10minView.time, endDate), - ), - ) - .orderBy(desc(measurements10minView.time)) - } else if (aggregation === '1h') { - return drizzleClient - .select() - .from(measurements1hourView) - .where( - and( - eq(measurements1hourView.sensorId, sensorId), - gte(measurements1hourView.time, startDate), - lte(measurements1hourView.time, endDate), - ), - ) - .orderBy(desc(measurements1hourView.time)) - } else if (aggregation === '1d') { - return drizzleClient - .select() - .from(measurements1dayView) - .where( - and( - eq(measurements1dayView.sensorId, sensorId), - gte(measurements1dayView.time, startDate), - lte(measurements1dayView.time, endDate), - ), - ) - .orderBy(desc(measurements1dayView.time)) - } else if (aggregation === '1m') { - return drizzleClient - .select() - .from(measurements1monthView) - .where( - and( - eq(measurements1monthView.sensorId, sensorId), - gte(measurements1monthView.time, startDate), - lte(measurements1monthView.time, endDate), - ), - ) - .orderBy(desc(measurements1monthView.time)) - } else if (aggregation === '1y') { - return drizzleClient - .select() - .from(measurements1yearView) - .where( - and( - eq(measurements1yearView.sensorId, sensorId), - gte(measurements1yearView.time, startDate), - lte(measurements1yearView.time, endDate), - ), - ) - .orderBy(desc(measurements1yearView.time)) - } - // If aggregation is not specified or different from "15m" and "1d", fetch default measurements. - return drizzleClient.query.measurement.findMany({ - where: (measurement, { eq, gte, lte }) => - and( - eq(measurement.sensorId, sensorId), - gte(measurement.time, startDate), - lte(measurement.time, endDate), - ), - orderBy: [desc(measurement.time)], - with: { - location: { - // https://github.com/drizzle-team/drizzle-orm/pull/2778 - // with: { - // geometry: true - // }, - columns: { - id: true, - }, - extras: { - x: sql`ST_X(${location.location})`.as('x'), - y: sql`ST_Y(${location.location})`.as('y'), - }, - }, - }, - }) - } + // If both start date and end date are provided, filter measurements within the specified time range. + if (startDate && endDate) { + // Check the aggregation level for measurements and fetch accordingly. + if (aggregation === "10m") { + return drizzleClient + .select() + .from(measurements10minView) + .where( + and( + eq(measurements10minView.sensorId, sensorId), + gte(measurements10minView.time, startDate), + lte(measurements10minView.time, endDate), + ), + ) + .orderBy(desc(measurements10minView.time)); + } else if (aggregation === "1h") { + return drizzleClient + .select() + .from(measurements1hourView) + .where( + and( + eq(measurements1hourView.sensorId, sensorId), + gte(measurements1hourView.time, startDate), + lte(measurements1hourView.time, endDate), + ), + ) + .orderBy(desc(measurements1hourView.time)); + } else if (aggregation === "1d") { + return drizzleClient + .select() + .from(measurements1dayView) + .where( + and( + eq(measurements1dayView.sensorId, sensorId), + gte(measurements1dayView.time, startDate), + lte(measurements1dayView.time, endDate), + ), + ) + .orderBy(desc(measurements1dayView.time)); + } else if (aggregation === "1m") { + return drizzleClient + .select() + .from(measurements1monthView) + .where( + and( + eq(measurements1monthView.sensorId, sensorId), + gte(measurements1monthView.time, startDate), + lte(measurements1monthView.time, endDate), + ), + ) + .orderBy(desc(measurements1monthView.time)); + } else if (aggregation === "1y") { + return drizzleClient + .select() + .from(measurements1yearView) + .where( + and( + eq(measurements1yearView.sensorId, sensorId), + gte(measurements1yearView.time, startDate), + lte(measurements1yearView.time, endDate), + ), + ) + .orderBy(desc(measurements1yearView.time)); + } + // If aggregation is not specified or different from "15m" and "1d", fetch default measurements. + return drizzleClient.query.measurement.findMany({ + where: (measurement, { eq, gte, lte }) => + and( + eq(measurement.sensorId, sensorId), + gte(measurement.time, startDate), + lte(measurement.time, endDate), + ), + orderBy: [desc(measurement.time)], + with: { + location: { + // https://github.com/drizzle-team/drizzle-orm/pull/2778 + // with: { + // geometry: true + // }, + columns: { + id: true, + }, + extras: { + x: sql`ST_X(${location.location})`.as("x"), + y: sql`ST_Y(${location.location})`.as("y"), + }, + }, + }, + }); + } - // If only aggregation is provided, fetch measurements without considering time range. - if (aggregation === '10m') { - return drizzleClient - .select() - .from(measurements10minView) - .where(eq(measurements10minView.sensorId, sensorId)) - .orderBy(desc(measurements10minView.time)) - } else if (aggregation === '1h') { - return drizzleClient - .select() - .from(measurements1hourView) - .where(eq(measurements1hourView.sensorId, sensorId)) - .orderBy(desc(measurements1hourView.time)) - } else if (aggregation === '1d') { - return drizzleClient - .select() - .from(measurements1dayView) - .where(eq(measurements1dayView.sensorId, sensorId)) - .orderBy(desc(measurements1dayView.time)) - } else if (aggregation === '1m') { - return drizzleClient - .select() - .from(measurements1monthView) - .where(eq(measurements1monthView.sensorId, sensorId)) - .orderBy(desc(measurements1monthView.time)) - } else if (aggregation === '1y') { - return drizzleClient - .select() - .from(measurements1yearView) - .where(eq(measurements1yearView.sensorId, sensorId)) - .orderBy(desc(measurements1yearView.time)) - } + // If only aggregation is provided, fetch measurements without considering time range. + if (aggregation === "10m") { + return drizzleClient + .select() + .from(measurements10minView) + .where(eq(measurements10minView.sensorId, sensorId)) + .orderBy(desc(measurements10minView.time)); + } else if (aggregation === "1h") { + return drizzleClient + .select() + .from(measurements1hourView) + .where(eq(measurements1hourView.sensorId, sensorId)) + .orderBy(desc(measurements1hourView.time)); + } else if (aggregation === "1d") { + return drizzleClient + .select() + .from(measurements1dayView) + .where(eq(measurements1dayView.sensorId, sensorId)) + .orderBy(desc(measurements1dayView.time)); + } else if (aggregation === "1m") { + return drizzleClient + .select() + .from(measurements1monthView) + .where(eq(measurements1monthView.sensorId, sensorId)) + .orderBy(desc(measurements1monthView.time)); + } else if (aggregation === "1y") { + return drizzleClient + .select() + .from(measurements1yearView) + .where(eq(measurements1yearView.sensorId, sensorId)) + .orderBy(desc(measurements1yearView.time)); + } - // If neither start date nor aggregation are specified, fetch default measurements with a limit of 20000. - return drizzleClient.query.measurement.findMany({ - where: (measurement, { eq }) => eq(measurement.sensorId, sensorId), - orderBy: [desc(measurement.time)], - with: { - location: { - // https://github.com/drizzle-team/drizzle-orm/pull/2778 - // with: { - // geometry: true - // }, - columns: { - id: true, - }, - extras: { - x: sql`ST_X(${location.location})`.as('x'), - y: sql`ST_Y(${location.location})`.as('y'), - }, - }, - }, - limit: 3600, // 60 measurements per hour * 24 hours * 2.5 days - }) + // If neither start date nor aggregation are specified, fetch default measurements with a limit of 20000. + return drizzleClient.query.measurement.findMany({ + where: (measurement, { eq }) => eq(measurement.sensorId, sensorId), + orderBy: [desc(measurement.time)], + with: { + location: { + // https://github.com/drizzle-team/drizzle-orm/pull/2778 + // with: { + // geometry: true + // }, + columns: { + id: true, + }, + extras: { + x: sql`ST_X(${location.location})`.as("x"), + y: sql`ST_Y(${location.location})`.as("y"), + }, + }, + }, + limit: 3600, // 60 measurements per hour * 24 hours * 2.5 days + }); } + export async function saveMeasurements( - device: MinimalDevice, - measurements: MeasurementWithLocation[], + device: MinimalDevice, + measurements: MeasurementWithLocation[] ): Promise { - if (!device) throw new Error('No device given!') - if (!Array.isArray(measurements)) throw new Error('Array expected') - - const sensorIds = device.sensors.map((s: any) => s.id) - const lastMeasurements: Record> = {} + if (!device) + throw new Error("No device given!") + if (!Array.isArray(measurements)) throw new Error("Array expected"); - // Validate and prepare measurements - for (let i = measurements.length - 1; i >= 0; i--) { - const m = measurements[i] + const sensorIds = device.sensors.map((s: any) => s.id); + const lastMeasurements: Record> = {}; - if (!sensorIds.includes(m.sensor_id)) { - const error = new Error( - `Measurement for sensor with id ${m.sensor_id} does not belong to box`, - ) - error.name = 'ModelError' - throw error - } + // Validate and prepare measurements + for (let i = measurements.length - 1; i >= 0; i--) { + const m = measurements[i]; - const now = new Date() - const maxFutureTime = 30 * 1000 // 30 seconds + if (!sensorIds.includes(m.sensor_id)) { + const error = new Error( + `Measurement for sensor with id ${m.sensor_id} does not belong to box` + ); + error.name = "ModelError"; + throw error; + } - const measurementTime = new Date(m.createdAt || Date.now()) - if (measurementTime.getTime() > now.getTime() + maxFutureTime) { - const error = new Error( - `Measurement timestamp is too far in the future: ${measurementTime.toISOString()}`, - ) - error.name = 'ModelError' - ;(error as any).type = 'UnprocessableEntityError' - throw error - } + const now = new Date(); + const maxFutureTime = 30 * 1000; // 30 seconds - if ( - !lastMeasurements[m.sensor_id] || - lastMeasurements[m.sensor_id].createdAt < measurementTime.toISOString() - ) { - lastMeasurements[m.sensor_id] = { - value: m.value, - createdAt: measurementTime.toISOString(), - sensorId: m.sensor_id, - } - } - } + const measurementTime = new Date(m.createdAt || Date.now()); + if (measurementTime.getTime() > now.getTime() + maxFutureTime) { + const error = new Error( + `Measurement timestamp is too far in the future: ${measurementTime.toISOString()}` + ); + error.name = "ModelError"; + (error as any).type = "UnprocessableEntityError"; + throw error; + } - // Track measurements that update device location (those with explicit locations) - const deviceLocationUpdates = getLocationUpdates(measurements) - const locations = await findOrCreateLocations(deviceLocationUpdates) + if (!lastMeasurements[m.sensor_id] || + lastMeasurements[m.sensor_id].createdAt < measurementTime.toISOString()) { + lastMeasurements[m.sensor_id] = { + value: m.value, + createdAt: measurementTime.toISOString(), + sensorId: m.sensor_id, + }; + } + } - // First, update device locations for all measurements with explicit locations - // This ensures the location history is complete before we infer locations - await addLocationUpdates(deviceLocationUpdates, device.id, locations) + // Track measurements that update device location (those with explicit locations) + const deviceLocationUpdates = getLocationUpdates(measurements); + const locations = await findOrCreateLocations(deviceLocationUpdates); + + // First, update device locations for all measurements with explicit locations + // This ensures the location history is complete before we infer locations + await addLocationUpdates(deviceLocationUpdates, device.id, locations); - // Note that the insertion of measurements and update of sensors need to be in one - // transaction, since otherwise other updates could get in between and the data would be - // inconsistent. This shouldn't be a problem for the updates above. - await drizzleClient.transaction(async (tx) => { - // Now process each measurement and infer locations if needed - await insertMeasurementsWithLocation(measurements, locations, device.id, tx) - // Update sensor lastMeasurement values - await updateLastMeasurements(lastMeasurements, tx) - }) + // Note that the insertion of measurements and update of sensors need to be in one + // transaction, since otherwise other updates could get in between and the data would be + // inconsistent. This shouldn't be a problem for the updates above. + await drizzleClient.transaction(async (tx) => { + // Now process each measurement and infer locations if needed + await insertMeasurementsWithLocation(measurements, locations, device.id, tx); + // Update sensor lastMeasurement values + await updateLastMeasurements(lastMeasurements, tx); + }); } + export async function insertMeasurements(measurements: any[]): Promise { - const measurementInserts = measurements.map((measurement) => ({ - sensorId: measurement.sensor_id, - value: measurement.value, - time: measurement.createdAt || new Date(), - })) + const measurementInserts = measurements.map(measurement => ({ + sensorId: measurement.sensor_id, + value: measurement.value, + time: measurement.createdAt || new Date(), + })); - await drizzleClient.insert(measurement).values(measurementInserts) + await drizzleClient.insert(measurement).values(measurementInserts); } export async function deleteMeasurementsForSensor(sensorId: string) { - return await drizzleClient - .delete(measurement) - .where(eq(measurement.sensorId, sensorId)) + return await drizzleClient.delete(measurement).where(eq(measurement.sensorId, sensorId)); } export async function deleteMeasurementsForTime(date: Date) { - return await drizzleClient - .delete(measurement) - .where(eq(measurement.time, date)) + return await drizzleClient.delete(measurement).where(eq(measurement.time, date)); } + diff --git a/app/models/measurement.stream.server.ts b/app/models/measurement.stream.server.ts deleted file mode 100644 index 734014fe..00000000 --- a/app/models/measurement.stream.server.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { pg } from '~/db.server' - -/** - * Stream measurements in batches from Postgres - * @param sensorIds list of sensor IDs - * @param fromDate start of date range - * @param toDate end of date range - * @param bbox optional bounding box [lngSW, latSW, lngNE, latNE] - * @param batchSize number of rows per batch - */ -export async function* streamMeasurements( - sensorIds: string[], - fromDate: Date, - toDate: Date, - bbox?: any, - batchSize = 1000, -) { - // Build parameterized query values array preventing sql injections - const values: any[] = [ - sensorIds, // $1 - array of sensor IDs - fromDate instanceof Date ? fromDate.toISOString() : fromDate, // $2 - start date as ISO string - toDate instanceof Date ? toDate.toISOString() : toDate, // $3 - end date as ISO string - ] - - // check if sensor_id is in the array and filter by date range - let conditions = `m.sensor_id = ANY($1::text[]) AND m.time BETWEEN $2 AND $3` - - if (bbox) { - const [lngSW, latSW, lngNE, latNE] = bbox - values.push(lngSW, latSW, lngNE, latNE) - const idx = values.length - 4 // start index of bbox params in values (0-based -> $n numbering) - // NOTE: pg placeholders are 1-based, so use idx + 1 .. idx + 4 - conditions += ` AND ( - m.location_id IS NULL OR - ST_Contains( - ST_MakeEnvelope($${idx + 1}::double precision, $${idx + 2}::double precision, $${idx + 3}::double precision, $${idx + 4}::double precision, 4326), - l.location - ) - )` - } - - const sqlQuery = ` - SELECT - m.sensor_id, - m.time, - m.value, - l.location, - m.location_id - FROM measurement m - LEFT JOIN location l ON m.location_id = l.id - WHERE ${conditions} - ORDER BY m.time ASC - ` - - const cursor = pg.unsafe(sqlQuery, values).cursor(batchSize) - - for await (const rows of cursor) { - yield rows - } -} diff --git a/app/models/sensor.server.ts b/app/models/sensor.server.ts index cc087225..d452452d 100644 --- a/app/models/sensor.server.ts +++ b/app/models/sensor.server.ts @@ -1,10 +1,8 @@ -import { eq, sql, inArray, and } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { drizzleClient } from '~/db.server' -import { type BoxesDataQueryParams } from '~/lib/api-schemas/boxes-data-query-schema' import { type Measurement, sensor, - device, type Sensor, type SensorWithLatestMeasurement, } from '~/schema' @@ -202,93 +200,3 @@ export function getSensor(id: Sensor['id']) { export function deleteSensor(id: Sensor['id']) { return drizzleClient.delete(sensor).where(eq(sensor.id, id)) } - -/** - * Find matching devices+their sensors based on phenomenon or grouptag and device-level filters. - * Returns sensorsMap (sensorId -> augmented sensor metadata) and sensorIds array. - */ -export async function findMatchingSensors(params: BoxesDataQueryParams) { - const { boxid, exposure, phenomenon, grouptag } = params - - // Build device-level conditions - const deviceConditions = [] - - if (grouptag) { - deviceConditions.push(sql`${grouptag} = ANY(${device.tags})`) - } - - if (boxid) { - deviceConditions.push(inArray(device.id, boxid)) - } - - if (exposure) { - deviceConditions.push(inArray(device.exposure, exposure)) - } - - // Build sensor-level conditions - const sensorConditions = [] - - if (phenomenon) { - sensorConditions.push(eq(sensor.title, phenomenon)) - } - - const rows = await drizzleClient - .select({ - deviceId: device.id, - deviceName: device.name, - deviceExposure: device.exposure, - deviceLat: device.latitude, - deviceLon: device.longitude, - sensorId: sensor.id, - sensorTitle: sensor.title, - sensorUnit: sensor.unit, - sensorType: sensor.sensorType, - }) - .from(device) - .innerJoin(sensor, eq(sensor.deviceId, device.id)) - .where( - and( - sensorConditions.length > 0 ? and(...sensorConditions) : undefined, - deviceConditions.length > 0 ? and(...deviceConditions) : undefined, - ), - ) - - if (!rows || rows.length === 0) { - throw new Response('No senseBoxes found', { status: 404 }) - } - - const sensorsMap: Record< - string, - { - sensorId: string - boxId: string - boxName: string - exposure: string | null - lat: number - lon: number - height?: number - phenomenon: string | null - unit: string | null - sensorType: string | null - } - > = {} - - for (const r of rows) { - if (r.sensorId) { - sensorsMap[r.sensorId] = { - sensorId: r.sensorId, - boxId: r.deviceId, - boxName: r.deviceName, - exposure: r.deviceExposure, - lat: r.deviceLat, - lon: r.deviceLon, - height: undefined, - phenomenon: r.sensorTitle, - unit: r.sensorUnit, - sensorType: r.sensorType, - } - } - } - - return { sensorsMap, sensorIds: Object.keys(sensorsMap) } -} diff --git a/app/routes/api.boxes.$deviceId.data.$sensorId.ts b/app/routes/api.boxes.$deviceId.data.$sensorId.ts index d5e6722c..8a4bbd93 100644 --- a/app/routes/api.boxes.$deviceId.data.$sensorId.ts +++ b/app/routes/api.boxes.$deviceId.data.$sensorId.ts @@ -1,17 +1,10 @@ -import { - type Params, - type LoaderFunction, - type LoaderFunctionArgs, -} from 'react-router' -import { - type TransformedMeasurement, - transformOutliers, -} from '~/lib/outlier-transform' -import { getMeasurements } from '~/models/sensor.server' -import { type Measurement } from '~/schema' -import { convertToCsv } from '~/utils/csv' -import { parseDateParam, parseEnumParam } from '~/utils/param-utils' -import { StandardResponse } from '~/utils/response-utils' +import { type Params, type LoaderFunction, type LoaderFunctionArgs } from "react-router"; +import { type TransformedMeasurement, transformOutliers } from "~/lib/outlier-transform"; +import { getMeasurements } from "~/models/sensor.server"; +import { type Measurement } from "~/schema"; +import { convertToCsv } from "~/utils/csv"; +import { parseDateParam, parseEnumParam } from "~/utils/param-utils"; +import { StandardResponse } from "~/utils/response-utils"; /** * @openapi @@ -146,151 +139,121 @@ import { StandardResponse } from '~/utils/response-utils' */ export const loader: LoaderFunction = async ({ - request, - params, + request, + params, }: LoaderFunctionArgs): Promise => { - try { - const collected = collectParameters(request, params) - if (collected instanceof Response) return collected - const { - sensorId, - outliers, - outlierWindow, - fromDate, - toDate, - format, - download, - delimiter, - } = collected + try { - let meas: Measurement[] | TransformedMeasurement[] = await getMeasurements( - sensorId, - fromDate.toISOString(), - toDate.toISOString(), - ) - if (meas == null) return StandardResponse.notFound('Device not found.') + const collected = collectParameters(request, params); + if (collected instanceof Response) + return collected; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {deviceId, sensorId, outliers, outlierWindow, fromDate, toDate, format, download, delimiter} = collected; - if (outliers) - meas = transformOutliers(meas, outlierWindow, outliers == 'replace') + let meas: Measurement[] | TransformedMeasurement[] = await getMeasurements(sensorId, fromDate.toISOString(), toDate.toISOString()); + if (meas == null) + return StandardResponse.notFound("Device not found."); + + if (outliers) + meas = transformOutliers(meas, outlierWindow, outliers == "replace"); - let headers: HeadersInit = { - 'content-type': - format == 'json' - ? 'application/json; charset=utf-8' - : 'text/csv; charset=utf-8', - } - if (download) - headers['Content-Disposition'] = - `attachment; filename=${sensorId}.${format}` + let headers: HeadersInit = { + "content-type": format == "json" ? "application/json; charset=utf-8" : "text/csv; charset=utf-8", + }; + if (download) + headers["Content-Disposition"] = `attachment; filename=${sensorId}.${format}`; - const responseInit: ResponseInit = { - status: 200, - headers: headers, - } + const responseInit: ResponseInit = { + status: 200, + headers: headers, + }; - if (format == 'json') return Response.json(meas, responseInit) - else { - const csv = getCsv(meas, delimiter == 'comma' ? ',' : ';') - return new Response(csv, responseInit) - } - } catch (err) { - console.warn(err) - return StandardResponse.internalServerError() - } -} + if (format == "json") + return Response.json(meas, responseInit); + else { + const csv = getCsv(meas, delimiter == "comma" ? "," : ";"); + return new Response(csv, responseInit) + } -function collectParameters( - request: Request, - params: Params, -): - | Response - | { - deviceId: string - sensorId: string - outliers: string | null - outlierWindow: number - fromDate: Date - toDate: Date - format: string | null - download: boolean | null - delimiter: string - } { - // deviceId is there for legacy reasons - const deviceId = params.deviceId - if (deviceId === undefined) - return StandardResponse.badRequest('Invalid device id specified') - const sensorId = params.sensorId - if (sensorId === undefined) - return StandardResponse.badRequest('Invalid sensor id specified') + } catch (err) { + console.warn(err); + return StandardResponse.internalServerError(); + } +}; - const url = new URL(request.url) +function collectParameters(request: Request, params: Params): + Response | { + deviceId: string, + sensorId: string, + outliers: string | null, + outlierWindow: number, + fromDate: Date, + toDate: Date, + format: string | null, + download: boolean | null, + delimiter: string + } { + // deviceId is there for legacy reasons + const deviceId = params.deviceId; + if (deviceId === undefined) + return StandardResponse.badRequest("Invalid device id specified"); + const sensorId = params.sensorId; + if (sensorId === undefined) + return StandardResponse.badRequest("Invalid sensor id specified"); - const outliers = parseEnumParam(url, 'outliers', ['replace', 'mark'], null) - if (outliers instanceof Response) return outliers + const url = new URL(request.url); - const outlierWindowParam = url.searchParams.get('outlier-window') - let outlierWindow: number = 15 - if (outlierWindowParam !== null) { - if ( - Number.isNaN(outlierWindowParam) || - Number(outlierWindowParam) < 1 || - Number(outlierWindowParam) > 50 - ) - return StandardResponse.badRequest( - 'Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50', - ) - outlierWindow = Number(outlierWindowParam) - } + const outliers = parseEnumParam(url, "outliers", ["replace", "mark"], null) + if (outliers instanceof Response) + return outliers; - const fromDate = parseDateParam( - url, - 'from-date', - new Date(new Date().setDate(new Date().getDate() - 2)), - ) - if (fromDate instanceof Response) return fromDate + const outlierWindowParam = url.searchParams.get("outlier-window") + let outlierWindow: number = 15; + if (outlierWindowParam !== null) { + if (Number.isNaN(outlierWindowParam) || Number(outlierWindowParam) < 1 || Number(outlierWindowParam) > 50) + return StandardResponse.badRequest("Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50"); + outlierWindow = Number(outlierWindowParam); + } - const toDate = parseDateParam(url, 'to-date', new Date()) - if (toDate instanceof Response) return toDate + const fromDate = parseDateParam(url, "from-date", new Date(new Date().setDate(new Date().getDate() - 2))) + if (fromDate instanceof Response) + return fromDate - const format = parseEnumParam(url, 'format', ['json', 'csv'], 'json') - if (format instanceof Response) return format + const toDate = parseDateParam(url, "to-date", new Date()) + if (toDate instanceof Response) + return toDate - const downloadParam = parseEnumParam(url, 'download', ['true', 'false'], null) - if (downloadParam instanceof Response) return downloadParam - const download = downloadParam == null ? null : downloadParam === 'true' + const format = parseEnumParam(url, "format", ["json", "csv"], "json"); + if (format instanceof Response) + return format - const delimiter = parseEnumParam( - url, - 'delimiter', - ['comma', 'semicolon'], - 'comma', - ) - if (delimiter instanceof Response) return delimiter + const downloadParam = parseEnumParam(url, "download", ["true", "false"], null) + if (downloadParam instanceof Response) + return downloadParam + const download = downloadParam == null + ? null + : (downloadParam === "true"); - return { - deviceId, - sensorId, - outliers, - outlierWindow, - fromDate, - toDate, - format, - download, - delimiter, - } -} + const delimiter = parseEnumParam(url, "delimiter", ["comma", "semicolon"], "comma"); + if (delimiter instanceof Response) + return delimiter; -function getCsv( - meas: Measurement[] | TransformedMeasurement[], - delimiter: string, -): string { - return convertToCsv( - ['createdAt', 'value'], - meas, - [ - (measurement) => measurement.time.toString(), - (measurement) => measurement.value?.toString() ?? 'null', - ], - delimiter, - ) + return { + deviceId, + sensorId, + outliers, + outlierWindow, + fromDate, + toDate, + format, + download, + delimiter + }; } + +function getCsv(meas: Measurement[] | TransformedMeasurement[], delimiter: string): string { + return convertToCsv(["createdAt", "value"], meas, [ + measurement => measurement.time.toString(), + measurement => measurement.value?.toString() ?? "null" + ], delimiter) +} \ No newline at end of file diff --git a/app/routes/api.boxes.data.ts b/app/routes/api.boxes.data.ts deleted file mode 100644 index 6d500a6a..00000000 --- a/app/routes/api.boxes.data.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { type LoaderFunctionArgs, type ActionFunctionArgs } from 'react-router' -import { parseBoxesDataQuery } from '~/lib/api-schemas/boxes-data-query-schema' -import { transformMeasurement } from '~/lib/measurement-service.server' -import { streamMeasurements } from '~/models/measurement.stream.server' -import { findMatchingSensors } from '~/models/sensor.server' -import { escapeCSVValue } from '~/utils/csv' -import { StandardResponse } from '~/utils/response-utils' - -function createDownloadFilename( - date: Date, - action: string, - params: string[], - format: string, -) { - return `opensensemap_org-${action}-${encodeURI(params.join('-'))}-${date - .toISOString() - .replace(/-|:|\.\d*Z/g, '') - .replace('T', '_')}.${format}` -} - -export async function loader({ request }: LoaderFunctionArgs) { - try { - const params = await parseBoxesDataQuery(request) - - const { sensorsMap, sensorIds } = await findMatchingSensors(params) - - if (sensorIds.length === 0) { - return StandardResponse.notFound('No matching sensors found') - } - - const headers = new Headers() - headers.set( - 'Content-Type', - params.format === 'csv' ? 'text/csv' : 'application/json', - ) - if (params.download) { - const filename = createDownloadFilename( - new Date(), - 'download', - [params.phenomenon || 'data'], - params.format, - ) - headers.set('Content-Disposition', `attachment; filename=${filename}`) - } - - const delimiterChar = params.delimiter === 'semicolon' ? ';' : ',' - - const stream = new ReadableStream({ - async start(controller) { - try { - const encoder = new TextEncoder() - let isFirst = true - - // Write CSV header or JSON opening bracket - if (params.format === 'csv') { - const header = params.columns.join(delimiterChar) + '\n' - controller.enqueue(encoder.encode(header)) - } else { - controller.enqueue(encoder.encode('[')) - } - - for await (const batch of streamMeasurements( - sensorIds, - params.fromDate, - params.toDate, - params.bbox, - )) { - for (const measurement of batch) { - const transformed = transformMeasurement( - { - sensorId: measurement.sensor_id, - createdAt: measurement.time - ? new Date(measurement.time) - : null, - value: measurement.value, - locationId: measurement.location_id ?? null, - }, - sensorsMap, - {}, - params.columns, - ) - - let line: string - if (params.format === 'csv') { - line = - params.columns - .map((col: string) => - escapeCSVValue( - (transformed as Record)[col], - delimiterChar, - ), - ) - .join(delimiterChar) + '\n' - } else { - // Format as JSON - if (!isFirst) { - line = ',' + JSON.stringify(transformed) - } else { - line = JSON.stringify(transformed) - isFirst = false - } - } - - controller.enqueue(encoder.encode(line)) - } - } - - // Close JSON array - if (params.format === 'json') { - controller.enqueue(encoder.encode(']')) - } - - controller.close() - } catch (error) { - console.error('Stream error:', error) - controller.error(error) - } - }, - }) - - return new Response(stream, { headers }) - } catch (err) { - if (err instanceof Response) throw err - return StandardResponse.internalServerError() - } -} - -export async function action(args: ActionFunctionArgs) { - return loader({ - request: args.request, - params: args.params as any, - context: args.context as any, - }) -} diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts index ef39f30d..e867dbbc 100644 --- a/app/routes/api.boxes.ts +++ b/app/routes/api.boxes.ts @@ -1,10 +1,10 @@ -import { type ActionFunction, type ActionFunctionArgs } from 'react-router' -import { transformDeviceToApiFormat } from '~/lib/device-transform' -import { CreateBoxSchema } from '~/lib/devices-service.server' -import { getUserFromJwt } from '~/lib/jwt' -import { createDevice } from '~/models/device.server' -import { type User } from '~/schema' -import { StandardResponse } from '~/utils/response-utils' +import { type ActionFunction, type ActionFunctionArgs } from "react-router"; +import { transformDeviceToApiFormat } from "~/lib/device-transform"; +import { CreateBoxSchema } from "~/lib/devices-service.server"; +import { getUserFromJwt } from "~/lib/jwt"; +import { createDevice } from "~/models/device.server"; +import { type User } from "~/schema"; +import { StandardResponse } from "~/utils/response-utils"; /** * @openapi @@ -325,83 +325,71 @@ import { StandardResponse } from '~/utils/response-utils' */ export const action: ActionFunction = async ({ - request, + request, }: ActionFunctionArgs) => { - try { - // Check authentication - const jwtResponse = await getUserFromJwt(request) + try { + // Check authentication + const jwtResponse = await getUserFromJwt(request); - if (typeof jwtResponse === 'string') - return StandardResponse.forbidden( - 'Invalid JWT authorization. Please sign in to obtain new JWT.', - ) + if (typeof jwtResponse === "string") + return StandardResponse.forbidden("Invalid JWT authorization. Please sign in to obtain new JWT."); - switch (request.method) { - case 'POST': - return await post(request, jwtResponse) - default: - return StandardResponse.methodNotAllowed('Method Not Allowed') - } - } catch (err) { - console.error('Error in action:', err) - return StandardResponse.internalServerError() - } -} + switch (request.method) { + case "POST": + return await post(request, jwtResponse); + default: + return StandardResponse.methodNotAllowed("Method Not Allowed"); + } + } catch (err) { + console.error("Error in action:", err); + return StandardResponse.internalServerError(); + } +}; async function post(request: Request, user: User) { - try { - // Parse and validate request body - let requestData - try { - requestData = await request.json() - } catch { - return StandardResponse.badRequest('Invalid JSON in request body') - } + try { + // Parse and validate request body + let requestData; + try { + requestData = await request.json(); + } catch { + return StandardResponse.badRequest("Invalid JSON in request body"); + } + + // Validate request data + const validationResult = CreateBoxSchema.safeParse(requestData); + if (!validationResult.success) { + return Response.json({ + code: "Bad Request", + message: "Invalid request data", + errors: validationResult.error.errors.map(err => `${err.path.join('.')}: ${err.message}`), + }, { status: 400 }); + } - // Validate request data - const validationResult = CreateBoxSchema.safeParse(requestData) - if (!validationResult.success) { - return Response.json( - { - code: 'Bad Request', - message: 'Invalid request data', - errors: validationResult.error.errors.map( - (err) => `${err.path.join('.')}: ${err.message}`, - ), - }, - { status: 400 }, - ) - } + const validatedData = validationResult.data; - const validatedData = validationResult.data - const sensorsProvided = validatedData.sensors?.length > 0 - // Extract longitude and latitude from location array [longitude, latitude] - const [longitude, latitude] = validatedData.location - const newBox = await createDevice( - { - name: validatedData.name, - exposure: validatedData.exposure, - model: sensorsProvided ? undefined : validatedData.model, - latitude: latitude, - longitude: longitude, - tags: validatedData.grouptag, - sensors: sensorsProvided - ? validatedData.sensors.map((s) => ({ - title: s.title, - sensorType: s.sensorType, - unit: s.unit, - })) - : undefined, - }, - user.id, - ) + // Extract longitude and latitude from location array [longitude, latitude] + const [longitude, latitude] = validatedData.location; + const newBox = await createDevice({ + name: validatedData.name, + exposure: validatedData.exposure, + model: validatedData.model, + latitude: latitude, + longitude: longitude, + tags: validatedData.grouptag, + sensors: validatedData.sensors.map(sensor => ({ + title: sensor.title, + sensorType: sensor.sensorType, + unit: sensor.unit, + })), + }, user.id); - // Build response object using helper function - const responseData = transformDeviceToApiFormat(newBox) + // Build response object using helper function + const responseData = transformDeviceToApiFormat(newBox); - return StandardResponse.created(responseData) - } catch (err) { - console.error('Error creating box:', err) - return StandardResponse.internalServerError() - } + return StandardResponse.created(responseData); + } catch (err) { + console.error("Error creating box:", err); + return StandardResponse.internalServerError(); + } } diff --git a/app/routes/api.device.$deviceId.ts b/app/routes/api.device.$deviceId.ts index 663c68f6..5d31aa3c 100644 --- a/app/routes/api.device.$deviceId.ts +++ b/app/routes/api.device.$deviceId.ts @@ -1,13 +1,6 @@ -import { type ActionFunctionArgs, type LoaderFunctionArgs } from 'react-router' -import { transformDeviceToApiFormat } from '~/lib/device-transform' -import { getUserFromJwt } from '~/lib/jwt' -import { - DeviceUpdateError, - getDevice, - updateDevice, - type UpdateDeviceArgs, -} from '~/models/device.server' -import { StandardResponse } from '~/utils/response-utils' +import { type LoaderFunctionArgs } from "react-router"; +import { getDevice } from "~/models/device.server"; +import { StandardResponse } from "~/utils/response-utils"; /** * @openapi @@ -70,185 +63,21 @@ import { StandardResponse } from '~/utils/response-utils' * description: Internal server error */ export async function loader({ params }: LoaderFunctionArgs) { - const { deviceId } = params + const { deviceId } = params; - if (!deviceId) return StandardResponse.badRequest('Device ID is required.') + if (!deviceId) + return StandardResponse.badRequest("Device ID is required."); - try { - const device = await getDevice({ id: deviceId }) + try { + const device = await getDevice({ id: deviceId }); - if (!device) return StandardResponse.notFound('Device not found.') + if (!device) + return StandardResponse.notFound("Device not found."); - return StandardResponse.ok(device) - } catch (error) { - console.error('Error fetching box:', error) + return StandardResponse.ok(device); + } catch (error) { + console.error("Error fetching box:", error); - if (error instanceof Response) { - throw error - } - - return new Response( - JSON.stringify({ error: 'Internal server error while fetching box' }), - { - status: 500, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - }, - }, - ) - } -} - -export async function action({ request, params }: ActionFunctionArgs) { - const { deviceId } = params - - if (!deviceId) { - return Response.json({ error: 'Device ID is required.' }, { status: 400 }) - } - - const jwtResponse = await getUserFromJwt(request) - - if (typeof jwtResponse === 'string') { - return Response.json( - { - code: 'Forbidden', - message: - 'Invalid JWT authorization. Please sign in to obtain a new JWT.', - }, - { status: 403 }, - ) - } - - switch (request.method) { - case 'PUT': - return await put(request, jwtResponse, deviceId) - default: - return Response.json({ message: 'Method Not Allowed' }, { status: 405 }) - } -} - -async function put(request: Request, user: any, deviceId: string) { - const body = await request.json() - - const currentDevice = await getDevice({ id: deviceId }) - if (!currentDevice) { - return Response.json( - { code: 'NotFound', message: 'Device not found' }, - { status: 404 }, - ) - } - - // Check for conflicting parameters (backwards compatibility) - if (body.sensors && body.addons?.add) { - return Response.json( - { - code: 'BadRequest', - message: 'sensors and addons can not appear in the same request.', - }, - { status: 400 }, - ) - } - - if (body.addons?.add === 'feinstaub') { - const homeModels = ['homeWifi', 'homeEthernet'] - if (currentDevice.model && homeModels.includes(currentDevice.model)) { - body.model = `${currentDevice.model}Feinstaub` - - const hasPM10 = currentDevice.sensors.some( - (s) => s.sensorType === 'SDS 011' && s.title === 'PM10', - ) - const hasPM25 = currentDevice.sensors.some( - (s) => s.sensorType === 'SDS 011' && s.title === 'PM2.5', - ) - - if (!hasPM10 || !hasPM25) { - body.sensors = [ - ...(body.sensors ?? []), - !hasPM10 && { - new: true, - title: 'PM10', - unit: 'µg/m³', - sensorType: 'SDS 011', - // icon: 'osem-cloud', - }, - !hasPM25 && { - new: true, - title: 'PM2.5', - unit: 'µg/m³', - sensorType: 'SDS 011', - // icon: 'osem-cloud', - }, - ].filter(Boolean) - } - } - } - - // Handle addons (merge with grouptag) - if (body.addons?.add) { - const currentTags = Array.isArray(body.grouptag) ? body.grouptag : [] - body.grouptag = Array.from(new Set([...currentTags, body.addons.add])) - } - - // Handle image deletion - if (body.deleteImage === true) { - body.image = '' - } - - // Prepare location if provided - let locationData: { lat: number; lng: number; height?: number } | undefined - if (body.location) { - locationData = { - lat: body.location.lat, - lng: body.location.lng, - } - if (body.location.height !== undefined) { - locationData.height = body.location.height - } - } - - const updateArgs: UpdateDeviceArgs = { - name: body.name, - exposure: body.exposure, - description: body.description, - image: body.image, - model: body.model, - useAuth: body.useAuth, - link: body.weblink, - location: locationData, - grouptag: body.grouptag, - sensors: body.sensors, - } - - try { - const updatedDevice = await updateDevice(deviceId, updateArgs) - - const deviceWithSensors = await getDevice({ id: updatedDevice.id }) - - const apiResponse = transformDeviceToApiFormat(deviceWithSensors as any) - - return Response.json(apiResponse, { status: 200 }) - } catch (error) { - console.error('Error updating device:', error) - - // Handle specific device update errors - if (error instanceof DeviceUpdateError) { - return Response.json( - { - code: error.statusCode === 400 ? 'BadRequest' : 'NotFound', - message: error.message, - }, - { status: error.statusCode }, - ) - } - - // Return generic error for unexpected errors - return Response.json( - { - code: 'InternalServerError', - message: - error instanceof Error ? error.message : 'Failed to update device', - }, - { status: 500 }, - ) - } + return StandardResponse.internalServerError(); + } } diff --git a/app/routes/api.ts b/app/routes/api.ts index a25dbd3b..2a15d59d 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -1,224 +1,224 @@ -import { type LoaderFunctionArgs } from 'react-router' +import { type LoaderFunctionArgs } from "react-router"; -type RouteInfo = { path: string; method: 'GET' | 'PUT' | 'POST' | 'DELETE' } +type RouteInfo = { path: string; method: "GET" | "PUT" | "POST" | "DELETE" }; const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { - noauth: [ - { - path: '/', - method: 'GET', - }, - { - path: '/stats', - method: 'GET', - }, - { - path: '/tags', - method: 'GET', - }, - // { - // path: `statistics/idw`, - // method: "GET", + noauth: [ + { + path: "/", + method: "GET", + }, + { + path: "/stats", + method: "GET", + }, + { + path: "/tags", + method: "GET", + }, + // { + // path: `statistics/idw`, + // method: "GET", - // }, - // { - // path: `statistics/descriptive`, - // method: "GET", + // }, + // { + // path: `statistics/descriptive`, + // method: "GET", - // }, - { - path: `boxes`, - method: 'GET', - }, - { - path: `boxes/data`, - method: 'GET', - }, + // }, + { + path: `boxes`, + method: "GET", + }, + // { + // path: `boxes/data`, + // method: "GET", + // }, - // { - // path: `boxes/:boxId`, - // method: "GET", - // }, - { - path: `boxes/:boxId/sensors`, - method: 'GET', - }, - { - path: `boxes/:boxId/sensors/:sensorId`, - method: 'GET', - }, - // { - // path: `boxes/:boxId/data/:sensorId`, - // method: "GET", - // }, - // { - // path: `boxes/:boxId/locations`, - // method: "GET", - // }, - // { - // path: `boxes/data`, - // method: "POST", - // }, - { - path: `boxes/:boxId/data`, - method: 'POST', - }, - { - path: `boxes/:boxId/:sensorId`, - method: 'POST', - }, - { - path: `users/register`, - method: 'POST', - }, - { - path: `users/request-password-reset`, - method: 'POST', - }, - { - path: `users/password-reset`, - method: 'POST', - }, - { - path: `users/confirm-email`, - method: 'POST', - }, - { - path: `users/sign-in`, - method: 'POST', - }, - ], - auth: [ - { - path: `users/refresh-auth`, - method: 'POST', - }, - { - path: `users/me`, - method: 'GET', - }, - { - path: `users/me`, - method: 'PUT', - }, - { - path: `users/me/boxes`, - method: 'GET', - }, - { - path: `users/me/boxes/:boxId`, - method: 'GET', - }, - // { - // path: `boxes/:boxId/script`, - // method: "GET", - // }, - { - path: `boxes`, - method: 'POST', - }, - { - path: `boxes/claim`, - method: 'POST', - }, - { - path: `boxes/transfer`, - method: 'POST', - }, - { - path: `boxes/transfer`, - method: 'DELETE', - }, - { - path: `boxes/transfer/:boxId`, - method: 'GET', - }, - { - path: `boxes/transfer/:boxId`, - method: 'PUT', - }, - { - path: `boxes/:boxId`, - method: 'PUT', - }, - { - path: `boxes/:boxId`, - method: 'DELETE', - }, - // { - // path: `boxes/:boxId/:sensorId/measurements`, - // method: "DELETE", - // }, - { - path: `users/sign-out`, - method: 'POST', - }, - { - path: `users/me`, - method: 'DELETE', - }, - { - path: `users/me/resend-email-confirmation`, - method: 'POST', - }, - ], - // management: [ - // { - // path: `${managementPath}/boxes`, - // method: "GET", - // }, - // { - // path: `${managementPath}/boxes/:boxId`, - // method: "GET", - // }, - // { - // path: `${managementPath}/boxes/:boxId`, - // method: "PUT", - // }, - // { - // path: `${managementPath}/boxes/delete`, - // method: "POST", - // }, - // { - // path: `${managementPath}/users`, - // method: "GET", - // }, - // { - // path: `${managementPath}/users/:userId`, - // method: "GET", - // }, - // { - // path: `${managementPath}/users/:userId`, - // method: "PUT", - // }, - // { - // path: `${managementPath}/users/delete`, - // method: "POST", - // }, - // { - // path: `${managementPath}/users/:userId/exec`, - // method: "POST", - // }, - // ], -} + // { + // path: `boxes/:boxId`, + // method: "GET", + // }, + { + path: `boxes/:boxId/sensors`, + method: "GET", + }, + { + path: `boxes/:boxId/sensors/:sensorId`, + method: "GET", + }, + // { + // path: `boxes/:boxId/data/:sensorId`, + // method: "GET", + // }, + // { + // path: `boxes/:boxId/locations`, + // method: "GET", + // }, + // { + // path: `boxes/data`, + // method: "POST", + // }, + { + path: `boxes/:boxId/data`, + method: "POST", + }, + { + path: `boxes/:boxId/:sensorId`, + method: "POST", + }, + { + path: `users/register`, + method: "POST", + }, + { + path: `users/request-password-reset`, + method: "POST", + }, + { + path: `users/password-reset`, + method: "POST", + }, + { + path: `users/confirm-email`, + method: "POST", + }, + { + path: `users/sign-in`, + method: "POST", + }, + ], + auth: [ + { + path: `users/refresh-auth`, + method: "POST", + }, + { + path: `users/me`, + method: "GET", + }, + { + path: `users/me`, + method: "PUT", + }, + { + path: `users/me/boxes`, + method: "GET", + }, + { + path: `users/me/boxes/:boxId`, + method: "GET", + }, + // { + // path: `boxes/:boxId/script`, + // method: "GET", + // }, + { + path: `boxes`, + method: "POST", + }, + { + path: `boxes/claim`, + method: "POST", + }, + { + path: `boxes/transfer`, + method: "POST", + }, + { + path: `boxes/transfer`, + method: "DELETE", + }, + { + path: `boxes/transfer/:boxId`, + method: "GET", + }, + { + path: `boxes/transfer/:boxId`, + method: "PUT", + }, + // { + // path: `boxes/:boxId`, + // method: "PUT", + // }, + { + path: `boxes/:boxId`, + method: "DELETE", + }, + // { + // path: `boxes/:boxId/:sensorId/measurements`, + // method: "DELETE", + // }, + { + path: `users/sign-out`, + method: "POST", + }, + { + path: `users/me`, + method: "DELETE", + }, + { + path: `users/me/resend-email-confirmation`, + method: "POST", + }, + ], + // management: [ + // { + // path: `${managementPath}/boxes`, + // method: "GET", + // }, + // { + // path: `${managementPath}/boxes/:boxId`, + // method: "GET", + // }, + // { + // path: `${managementPath}/boxes/:boxId`, + // method: "PUT", + // }, + // { + // path: `${managementPath}/boxes/delete`, + // method: "POST", + // }, + // { + // path: `${managementPath}/users`, + // method: "GET", + // }, + // { + // path: `${managementPath}/users/:userId`, + // method: "GET", + // }, + // { + // path: `${managementPath}/users/:userId`, + // method: "PUT", + // }, + // { + // path: `${managementPath}/users/delete`, + // method: "POST", + // }, + // { + // path: `${managementPath}/users/:userId/exec`, + // method: "POST", + // }, + // ], +}; export async function loader({}: LoaderFunctionArgs) { - const lines = [ - `This is the openSenseMap API`, - 'You can find a detailed reference at https://docs.opensensemap.org\n', - 'Routes requiring no authentication:', - ] + const lines = [ + `This is the openSenseMap API`, + "You can find a detailed reference at https://docs.opensensemap.org\n", + "Routes requiring no authentication:", + ]; - for (const r of routes.noauth) lines.push(`${r.method}\t${r.path}`) + for (const r of routes.noauth) lines.push(`${r.method}\t${r.path}`); - lines.push('\nRoutes requiring valid authentication through JWT:') + lines.push("\nRoutes requiring valid authentication through JWT:"); - for (const r of routes.auth) lines.push(`${r.method}\t${r.path}`) + for (const r of routes.auth) lines.push(`${r.method}\t${r.path}`); - return new Response(lines.join('\n'), { - status: 200, - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - }, - }) + return new Response(lines.join("\n"), { + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + }); } diff --git a/app/routes/device.$deviceId.edit.general.tsx b/app/routes/device.$deviceId.edit.general.tsx index 743797e1..a58e162e 100644 --- a/app/routes/device.$deviceId.edit.general.tsx +++ b/app/routes/device.$deviceId.edit.general.tsx @@ -13,9 +13,9 @@ import { import invariant from 'tiny-invariant' import ErrorMessage from '~/components/error-message' import { - updateDevice, deleteDevice, getDeviceWithoutSensors, + updateDeviceInfo, } from '~/models/device.server' import { verifyLogin } from '~/models/user.server' import { getUserEmail, getUserId } from '~/utils/session.server' @@ -42,9 +42,6 @@ export async function action({ request, params }: ActionFunctionArgs) { const formData = await request.formData() const { intent, name, exposure, passwordDelete } = Object.fromEntries(formData) - - const exposureLowerCase = exposure?.toString().toLowerCase() - const errors = { exposure: exposure ? null : 'Invalid exposure.', passwordDelete: passwordDelete ? null : 'Password is required.', @@ -56,10 +53,10 @@ export async function action({ request, params }: ActionFunctionArgs) { invariant(typeof exposure === 'string', 'Device name is required.') if ( - exposureLowerCase !== 'indoor' && - exposureLowerCase !== 'outdoor' && - exposureLowerCase !== 'mobile' && - exposureLowerCase !== 'unknown' + exposure !== 'indoor' && + exposure !== 'outdoor' && + exposure !== 'mobile' && + exposure !== 'unknown' ) { return data({ errors: { @@ -72,7 +69,7 @@ export async function action({ request, params }: ActionFunctionArgs) { switch (intent) { case 'save': { - await updateDevice(deviceID, { name, exposure: exposureLowerCase }) + await updateDeviceInfo({ id: deviceID, name: name, exposure: exposure }) return data({ errors: { exposure: null, @@ -216,7 +213,8 @@ export default function () { > Exposure - + {/* changed the case of the option values to lowercase as the + server expects lowercase */}
{actionData?.errors?.password && (
- {t(actionData.errors.password)} + {actionData.errors.password}
)}
@@ -183,7 +183,7 @@ export default function LoginPage() {

{t("no_account_label")}{" "} diff --git a/app/routes/explore.tsx b/app/routes/explore.tsx index fa3584cd..88cc6478 100644 --- a/app/routes/explore.tsx +++ b/app/routes/explore.tsx @@ -1,426 +1,423 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { type FeatureCollection, type Point } from 'geojson' -import mapboxglcss from 'mapbox-gl/dist/mapbox-gl.css?url' -import { useState, useRef } from 'react' +import { type FeatureCollection, type Point } from "geojson"; +import mapboxglcss from "mapbox-gl/dist/mapbox-gl.css?url"; +import { useState, useRef } from "react"; import { - type MapLayerMouseEvent, - type MapRef, - MapProvider, - Layer, - Source, - Marker, -} from 'react-map-gl' + type MapLayerMouseEvent, + type MapRef, + MapProvider, + Layer, + Source, + Marker, +} from "react-map-gl"; import { - Outlet, - useNavigate, - useSearchParams, - useLoaderData, - useParams, - type LoaderFunctionArgs, - type LinksFunction, -} from 'react-router' -import type Supercluster from 'supercluster' -import ErrorMessage from '~/components/error-message' -import Header from '~/components/header' -import Map from '~/components/map' -import { phenomenonLayers, defaultLayer } from '~/components/map/layers' -import BoxMarker from '~/components/map/layers/cluster/box-marker' -import ClusterLayer from '~/components/map/layers/cluster/cluster-layer' -import Legend, { type LegendValue } from '~/components/map/legend' -import i18next from '~/i18next.server' -import { getDevices, getDevicesWithSensors } from '~/models/device.server' -import { getMeasurement } from '~/models/measurement.query.server' -import { getProfileByUserId } from '~/models/profile.server' -import { getSensors } from '~/models/sensor.server' -import { type Device } from '~/schema' -import { getFilteredDevices } from '~/utils' -import { getCSV, getJSON, getTXT } from '~/utils/file-exports' -import { getUser, getUserSession } from '~/utils/session.server' + Outlet, + useNavigate, + useSearchParams, + useLoaderData, + useParams, + type LoaderFunctionArgs, + type LinksFunction, +} from "react-router"; +import type Supercluster from "supercluster"; +import ErrorMessage from "~/components/error-message"; +import Header from "~/components/header"; +import Map from "~/components/map"; +import { phenomenonLayers, defaultLayer } from "~/components/map/layers"; +import BoxMarker from "~/components/map/layers/cluster/box-marker"; +import ClusterLayer from "~/components/map/layers/cluster/cluster-layer"; +import Legend, { type LegendValue } from "~/components/map/legend"; +import i18next from "~/i18next.server"; +import { getDevices, getDevicesWithSensors } from "~/models/device.server"; +import { getMeasurement } from "~/models/measurement.server"; +import { getProfileByUserId } from "~/models/profile.server"; +import { getSensors } from "~/models/sensor.server"; +import { type Device } from "~/schema"; +import { getFilteredDevices } from "~/utils"; +import { getCSV, getJSON, getTXT } from "~/utils/file-exports"; +import { getUser, getUserSession } from "~/utils/session.server"; export async function action({ request }: { request: Request }) { - const deviceLimit = 50 - const sensorIds: Array = [] - const measurements: Array = [] - const formdata = await request.formData() - const deviceIds = (formdata.get('devices') as string).split(',') - const format = formdata.get('format') as string - const aggregate = formdata.get('aggregate') as string - const includeFields = { - title: formdata.get('title') === 'on', - unit: formdata.get('unit') === 'on', - value: formdata.get('value') === 'on', - timestamp: formdata.get('timestamp') === 'on', - } - - if (deviceIds.length >= deviceLimit) { - return Response.json({ - error: 'error', - link: 'https://archive.opensensemap.org/', - }) - } - for (const device of deviceIds) { - const sensors = await getSensors(device) - for (const sensor of sensors) { - sensorIds.push(sensor.id) - const measurement = await getMeasurement(sensor.id, aggregate) - measurement.map((m: any) => { - m['title'] = sensor.title - m['unit'] = sensor.unit - }) - - measurements.push(measurement) - } - } - - let content = '' - let contentType = 'text/plain' - let fileName = '' - - if (format === 'csv') { - const result = getCSV(measurements, includeFields) - content = result.content - fileName = result.fileName - contentType = result.contentType - } else if (format === 'json') { - const result = getJSON(measurements, includeFields) - content = result.content - fileName = result.fileName - contentType = result.contentType - } else { - // txt format - const result = getTXT(measurements, includeFields) - content = result.content - fileName = result.fileName - contentType = result.contentType - } - - return Response.json({ - href: `data:${contentType};charset=utf-8,${encodeURIComponent(content)}`, - download: fileName, - }) + const deviceLimit = 50; + const sensorIds: Array = []; + const measurements: Array = []; + const formdata = await request.formData(); + const deviceIds = (formdata.get("devices") as string).split(","); + const format = formdata.get("format") as string; + const aggregate = formdata.get("aggregate") as string; + const includeFields = { + title: formdata.get("title") === "on", + unit: formdata.get("unit") === "on", + value: formdata.get("value") === "on", + timestamp: formdata.get("timestamp") === "on", + }; + + if (deviceIds.length >= deviceLimit) { + return Response.json({ + error: "error", + link: "https://archive.opensensemap.org/", + }); + } + for (const device of deviceIds) { + const sensors = await getSensors(device); + for (const sensor of sensors) { + sensorIds.push(sensor.id); + const measurement = await getMeasurement(sensor.id, aggregate); + measurement.map((m: any) => { + m["title"] = sensor.title; + m["unit"] = sensor.unit; + }); + + measurements.push(measurement); + } + } + + let content = ""; + let contentType = "text/plain"; + let fileName = ""; + + if (format === "csv") { + const result = getCSV(measurements, includeFields); + content = result.content; + fileName = result.fileName; + contentType = result.contentType; + } else if (format === "json") { + const result = getJSON(measurements, includeFields); + content = result.content; + fileName = result.fileName; + contentType = result.contentType; + } else { + // txt format + const result = getTXT(measurements, includeFields); + content = result.content; + fileName = result.fileName; + contentType = result.contentType; + } + + return Response.json({ + href: `data:${contentType};charset=utf-8,${encodeURIComponent(content)}`, + download: fileName, + }); } export type DeviceClusterProperties = - | Supercluster.PointFeature - | Supercluster.PointFeature< - Supercluster.ClusterProperties & { - categories: { - [x: number]: number - } - } - > + | Supercluster.PointFeature + | Supercluster.PointFeature< + Supercluster.ClusterProperties & { + categories: { + [x: number]: number; + }; + } + >; export async function loader({ request }: LoaderFunctionArgs) { - //* Get filter params - let locale = await i18next.getLocale(request) - const url = new URL(request.url) - const filterParams = url.search - const urlFilterParams = new URLSearchParams(url.search) - - // check if sensors are queried - if not get devices only to reduce load - const devices = !urlFilterParams.get('phenomenon') - ? await getDevices('geojson') - : await getDevicesWithSensors() - - const session = await getUserSession(request) - const message = session.get('global_message') || null - - var filteredDevices = getFilteredDevices(devices, urlFilterParams) - - const user = await getUser(request) - //const phenomena = await getPhenomena(); - - if (user) { - const profile = await getProfileByUserId(user.id) - const userLocale = user.language - ? user.language.split(/[_-]/)[0].toLowerCase() - : 'en' - return { - devices, - user, - profile, - filteredDevices, - filterParams, - locale: userLocale, - //phenomena - } - } - return { - devices, - user, - profile: null, - filterParams, - filteredDevices, - message, - locale, - //phenomena, - } + //* Get filter params + let locale = await i18next.getLocale(request); + const url = new URL(request.url); + const filterParams = url.search; + const urlFilterParams = new URLSearchParams(url.search); + + // check if sensors are queried - if not get devices only to reduce load + const devices = !urlFilterParams.get("phenomenon") + ? await getDevices("geojson") + : await getDevicesWithSensors(); + + const session = await getUserSession(request); + const message = session.get("global_message") || null; + + var filteredDevices = getFilteredDevices(devices, urlFilterParams); + + const user = await getUser(request); + //const phenomena = await getPhenomena(); + + if (user) { + const profile = await getProfileByUserId(user.id); + const userLocale = user.language + ? user.language.split(/[_-]/)[0].toLowerCase() + : "en"; + return { + devices, + user, + profile, + filteredDevices, + filterParams, + locale: userLocale, + //phenomena + }; + } + return { + devices, + user, + profile: null, + filterParams, + filteredDevices, + message, + locale + //phenomena, + }; } export const links: LinksFunction = () => { - return [ - { - rel: 'stylesheet', - href: mapboxglcss, - }, - ] -} + return [ + { + rel: "stylesheet", + href: mapboxglcss, + }, + ]; +}; // This is for the live data display. The 21-06-2023 works with the seed Data, for Production take now minus 10 minutes -let currentDate = new Date('2023-06-21T14:13:11.024Z') -if (process.env.NODE_ENV === 'production') { - currentDate = new Date(Date.now() - 1000 * 600) +let currentDate = new Date("2023-06-21T14:13:11.024Z"); +if (process.env.NODE_ENV === "production") { + currentDate = new Date(Date.now() - 1000 * 600); } export default function Explore() { - // data from our loader - const { - devices, - user, - profile, - filterParams, - filteredDevices, - message, - locale, - } = useLoaderData() - - const mapRef = useRef(null) - - // get map bounds - const [, setViewState] = useState({ - longitude: 7.628202, - latitude: 51.961563, - zoom: 2, - }) - const navigate = useNavigate() - // const [showSearch, setShowSearch] = useState(false); - const [selectedPheno, setSelectedPheno] = useState(undefined) - const [searchParams] = useSearchParams() - const [filteredData, setFilteredData] = useState< - GeoJSON.FeatureCollection - >({ - type: 'FeatureCollection', - features: [], - }) - - //listen to search params change - // useEffect(() => { - // //filters devices for pheno - // if (searchParams.has("mapPheno") && searchParams.get("mapPheno") != "all") { - // let sensorsFiltered: any = []; - // let currentParam = searchParams.get("mapPheno"); - // //check if pheno exists in sensor-wiki data - // let pheno = data.phenomena.filter( - // (pheno: any) => pheno.slug == currentParam?.toString(), - // ); - // if (pheno[0]) { - // setSelectedPheno(pheno[0]); - // data.devices.features.forEach((device: any) => { - // device.properties.sensors.forEach((sensor: Sensor) => { - // if ( - // sensor.sensorWikiPhenomenon == currentParam && - // sensor.lastMeasurement - // ) { - // const lastMeasurementDate = new Date( - // //@ts-ignore - // sensor.lastMeasurement.createdAt, - // ); - // //take only measurements in the last 10mins - // //@ts-ignore - // if (currentDate < lastMeasurementDate) { - // sensorsFiltered.push({ - // ...device, - // properties: { - // ...device.properties, - // sensor: { - // ...sensor, - // lastMeasurement: { - // //@ts-ignore - // value: parseFloat(sensor.lastMeasurement.value), - // //@ts-ignore - // createdAt: sensor.lastMeasurement.createdAt, - // }, - // }, - // }, - // }); - // } - // } - // }); - // return false; - // }); - // setFilteredData({ - // type: "FeatureCollection", - // features: sensorsFiltered, - // }); - // } - // } else { - // setSelectedPheno(undefined); - // } - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, [searchParams]); - - function calculateLabelPositions(length: number): string[] { - const positions: string[] = [] - for (let i = length - 1; i >= 0; i--) { - const position = - i === length - 1 ? '95%' : `${((i / (length - 1)) * 100).toFixed(0)}%` - positions.push(position) - } - return positions - } - - const legendLabels = () => { - const values = - //@ts-ignore - phenomenonLayers[selectedPheno.slug].paint['circle-color'].slice(3) - const numbers = values.filter((v: number | string) => typeof v === 'number') - const colors = values.filter((v: number | string) => typeof v === 'string') - const positions = calculateLabelPositions(numbers.length) - - const legend: LegendValue[] = [] - const length = numbers.length - for (let i = 0; i < length; i++) { - const legendObj: LegendValue = { - value: numbers[i], - color: colors[i], - position: positions[i], - } - legend.push(legendObj) - } - return legend - } - - // // /** - // // * Focus the search input when the search overlay is displayed - // // */ - // // const focusSearchInput = () => { - // // searchRef.current?.focus(); - // // }; - - // /** - // * Display the search overlay when the ctrl + k key combination is pressed - // */ - // useHotkeys([ - // [ - // "ctrl+K", - // () => { - // setShowSearch(!showSearch); - // setTimeout(() => { - // focusSearchInput(); - // }, 100); - // }, - // ], - // ]); - - const onMapClick = (e: MapLayerMouseEvent) => { - if (e.features && e.features.length > 0) { - const feature = e.features[0] - - if (feature.layer.id === 'phenomenon-layer') { - void navigate( - `/explore/${feature.properties?.id}?${searchParams.toString()}`, - ) - } - } - } - - const handleMouseMove = (e: mapboxgl.MapLayerMouseEvent) => { - if (e.features && e.features.length > 0) { - mapRef!.current!.getCanvas().style.cursor = 'pointer' - } else { - mapRef!.current!.getCanvas().style.cursor = '' - } - } - - //* fly to sensebox location when url inludes deviceId - const { deviceId } = useParams() - var deviceLoc: any - let selectedDevice: any - if (deviceId) { - selectedDevice = (devices as any).features.find( - (device: any) => device.properties.id === deviceId, - ) - deviceLoc = [ - selectedDevice?.properties.latitude, - selectedDevice?.properties.longitude, - ] - } - - const buildLayerFromPheno = (selectedPheno: any) => { - //TODO: ADD VALUES TO DEFAULTLAYER FROM selectedPheno.ROV or min/max from values. - return defaultLayer - } - - return ( -
- -
- {selectedPheno && ( - - )} - setViewState(evt.viewState)} - interactiveLayerIds={selectedPheno ? ['phenomenon-layer'] : []} - onClick={onMapClick} - onMouseMove={handleMouseMove} - ref={mapRef} - initialViewState={ - deviceId - ? { latitude: deviceLoc[0], longitude: deviceLoc[1], zoom: 10 } - : { latitude: 7, longitude: 52, zoom: 2 } - } - > - {!selectedPheno && ( - } - /> - )} - {selectedPheno && ( - } - cluster={false} - > - - - )} - - {/* Render BoxMarker for the selected device */} - {selectedDevice && deviceId && ( - - - - )} - - {/* (); + + const mapRef = useRef(null); + + // get map bounds + const [, setViewState] = useState({ + longitude: 7.628202, + latitude: 51.961563, + zoom: 2, + }); + const navigate = useNavigate(); + // const [showSearch, setShowSearch] = useState(false); + const [selectedPheno, setSelectedPheno] = useState( + undefined, + ); + const [searchParams] = useSearchParams(); + const [filteredData, setFilteredData] = useState< + GeoJSON.FeatureCollection + >({ + type: "FeatureCollection", + features: [], + }); + + //listen to search params change + // useEffect(() => { + // //filters devices for pheno + // if (searchParams.has("mapPheno") && searchParams.get("mapPheno") != "all") { + // let sensorsFiltered: any = []; + // let currentParam = searchParams.get("mapPheno"); + // //check if pheno exists in sensor-wiki data + // let pheno = data.phenomena.filter( + // (pheno: any) => pheno.slug == currentParam?.toString(), + // ); + // if (pheno[0]) { + // setSelectedPheno(pheno[0]); + // data.devices.features.forEach((device: any) => { + // device.properties.sensors.forEach((sensor: Sensor) => { + // if ( + // sensor.sensorWikiPhenomenon == currentParam && + // sensor.lastMeasurement + // ) { + // const lastMeasurementDate = new Date( + // //@ts-ignore + // sensor.lastMeasurement.createdAt, + // ); + // //take only measurements in the last 10mins + // //@ts-ignore + // if (currentDate < lastMeasurementDate) { + // sensorsFiltered.push({ + // ...device, + // properties: { + // ...device.properties, + // sensor: { + // ...sensor, + // lastMeasurement: { + // //@ts-ignore + // value: parseFloat(sensor.lastMeasurement.value), + // //@ts-ignore + // createdAt: sensor.lastMeasurement.createdAt, + // }, + // }, + // }, + // }); + // } + // } + // }); + // return false; + // }); + // setFilteredData({ + // type: "FeatureCollection", + // features: sensorsFiltered, + // }); + // } + // } else { + // setSelectedPheno(undefined); + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [searchParams]); + + function calculateLabelPositions(length: number): string[] { + const positions: string[] = []; + for (let i = length - 1; i >= 0; i--) { + const position = + i === length - 1 ? "95%" : `${((i / (length - 1)) * 100).toFixed(0)}%`; + positions.push(position); + } + return positions; + } + + const legendLabels = () => { + const values = + //@ts-ignore + phenomenonLayers[selectedPheno.slug].paint["circle-color"].slice(3); + const numbers = values.filter( + (v: number | string) => typeof v === "number", + ); + const colors = values.filter((v: number | string) => typeof v === "string"); + const positions = calculateLabelPositions(numbers.length); + + const legend: LegendValue[] = []; + const length = numbers.length; + for (let i = 0; i < length; i++) { + const legendObj: LegendValue = { + value: numbers[i], + color: colors[i], + position: positions[i], + }; + legend.push(legendObj); + } + return legend; + }; + + // // /** + // // * Focus the search input when the search overlay is displayed + // // */ + // // const focusSearchInput = () => { + // // searchRef.current?.focus(); + // // }; + + // /** + // * Display the search overlay when the ctrl + k key combination is pressed + // */ + // useHotkeys([ + // [ + // "ctrl+K", + // () => { + // setShowSearch(!showSearch); + // setTimeout(() => { + // focusSearchInput(); + // }, 100); + // }, + // ], + // ]); + + const onMapClick = (e: MapLayerMouseEvent) => { + if (e.features && e.features.length > 0) { + const feature = e.features[0]; + + if (feature.layer.id === "phenomenon-layer") { + void navigate( + `/explore/${feature.properties?.id}?${searchParams.toString()}`, + ); + } + } + }; + + const handleMouseMove = (e: mapboxgl.MapLayerMouseEvent) => { + if (e.features && e.features.length > 0) { + mapRef!.current!.getCanvas().style.cursor = "pointer"; + } else { + mapRef!.current!.getCanvas().style.cursor = ""; + } + }; + + //* fly to sensebox location when url inludes deviceId + const { deviceId } = useParams(); + var deviceLoc: any; + let selectedDevice: any; + if (deviceId) { + selectedDevice = (devices as any).features.find( + (device: any) => device.properties.id === deviceId, + ); + deviceLoc = [ + selectedDevice?.properties.latitude, + selectedDevice?.properties.longitude, + ]; + } + + const buildLayerFromPheno = (selectedPheno: any) => { + //TODO: ADD VALUES TO DEFAULTLAYER FROM selectedPheno.ROV or min/max from values. + return defaultLayer; + }; + + return ( +
+ +
+ {selectedPheno && ( + + )} + setViewState(evt.viewState)} + interactiveLayerIds={selectedPheno ? ["phenomenon-layer"] : []} + onClick={onMapClick} + onMouseMove={handleMouseMove} + ref={mapRef} + initialViewState={ + deviceId + ? { latitude: deviceLoc[0], longitude: deviceLoc[1], zoom: 10 } + : { latitude: 7, longitude: 52, zoom: 2 } + } + > + {!selectedPheno && ( + } + /> + )} + {selectedPheno && ( + } + cluster={false} + > + + + )} + + {/* Render BoxMarker for the selected device */} + {selectedDevice && deviceId && ( + + + + )} + + {/* */} - - - -
- ) + +
+ +
+ ); } export function ErrorBoundary() { - return ( -
- -
- ) + return ( +
+ +
+ ); } diff --git a/app/routes/profile.$username.tsx b/app/routes/profile.$username.tsx index 72daaaa4..02d243f4 100644 --- a/app/routes/profile.$username.tsx +++ b/app/routes/profile.$username.tsx @@ -1,7 +1,6 @@ -import { useTranslation } from 'react-i18next' import { type LoaderFunctionArgs, redirect, useLoaderData } from 'react-router' import ErrorMessage from '~/components/error-message' -import { getColumns } from '~/components/mydevices/dt/columns' +import { columns } from '~/components/mydevices/dt/columns' import { DataTable } from '~/components/mydevices/dt/data-table' import { NavBar } from '~/components/nav-bar' import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar' @@ -80,9 +79,6 @@ export default function () { const { profile, sensorsCount, measurementsCount } = useLoaderData() - const { t } = useTranslation('profile') - const columnsTranslation = useTranslation('data-table') - // const sortedBadges = sortBadges(allBadges, userBackpack); return ( @@ -107,10 +103,8 @@ export default function () { {profile?.user?.name || ''}

- {t('user_since')}{' '} - {new Date(profile?.user?.createdAt || '').toLocaleDateString( - t('locale'), - )} + User since{' '} + {new Date(profile?.user?.createdAt || '').toLocaleDateString()}

@@ -120,7 +114,7 @@ export default function () { {profile?.user?.devices.length} - {t('devices')} + Devices
@@ -128,7 +122,7 @@ export default function () { {sensorsCount} - {t('sensors')} + Sensors
@@ -136,7 +130,7 @@ export default function () { {measurementsCount} - {t('measurements')} + Measurements
{/*
@@ -144,7 +138,7 @@ export default function () { {userBackpack.length} - {t("badges")} + Badges
*/} @@ -203,12 +197,9 @@ export default function () { {profile?.user?.devices && ( <>
- {t('devices')} + Devices
- + )} diff --git a/app/routes/settings.account.tsx b/app/routes/settings.account.tsx index a35c1ca9..5e6c20bb 100644 --- a/app/routes/settings.account.tsx +++ b/app/routes/settings.account.tsx @@ -1,215 +1,206 @@ -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useEffect, useRef, useState } from "react"; +import { Form, useActionData, useLoaderData , data, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router"; +import invariant from "tiny-invariant"; +import { Button } from "~/components/ui/button"; import { - Form, - useActionData, - useLoaderData, - data, - redirect, - type ActionFunctionArgs, - type LoaderFunctionArgs, -} from 'react-router' -import invariant from 'tiny-invariant' -import { Button } from '~/components/ui/button' + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '~/components/ui/card' -import { Input } from '~/components/ui/input' -import { Label } from '~/components/ui/label' + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { useToast } from "~/components/ui/use-toast"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '~/components/ui/select' -import { useToast } from '~/components/ui/use-toast' -import { - getUserByEmail, - updateUserName, - updateUserlocale, - verifyLogin, -} from '~/models/user.server' -import { getUserEmail, getUserId } from '~/utils/session.server' + getUserByEmail, + updateUserName, + updateUserlocale, + verifyLogin, +} from "~/models/user.server"; +import { getUserEmail, getUserId } from "~/utils/session.server"; //***************************************************** export async function loader({ request }: LoaderFunctionArgs) { - // If user is not logged in, redirect to home - const userId = await getUserId(request) - if (!userId) return redirect('/') + // If user is not logged in, redirect to home + const userId = await getUserId(request); + if (!userId) return redirect("/"); - // Get user email and load user data - const userEmail = await getUserEmail(request) - invariant(userEmail, `Email not found!`) - const userData = await getUserByEmail(userEmail) - return userData + // Get user email and load user data + const userEmail = await getUserEmail(request); + invariant(userEmail, `Email not found!`); + const userData = await getUserByEmail(userEmail); + return userData; } //***************************************************** export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData() - const { name, passwordUpdate, email, language } = Object.fromEntries(formData) + const formData = await request.formData(); + const { name, passwordUpdate, email, language } = + Object.fromEntries(formData); - const errors = { - name: name ? null : 'Invalid name', - email: email ? null : 'Invalid email', - passwordUpdate: passwordUpdate ? null : 'Password is required', - } + const errors = { + name: name ? null : "Invalid name", + email: email ? null : "Invalid email", + passwordUpdate: passwordUpdate ? null : "Password is required", + }; - invariant(typeof name === 'string', 'name must be a string') - invariant(typeof email === 'string', 'email must be a string') - invariant(typeof passwordUpdate === 'string', 'password must be a string') - invariant(typeof language === 'string', 'language must be a string') + invariant(typeof name === "string", "name must be a string"); + invariant(typeof email === "string", "email must be a string"); + invariant(typeof passwordUpdate === "string", "password must be a string"); + invariant(typeof language === "string", "language must be a string"); - // Validate password - if (errors.passwordUpdate) { - return data( - { - errors: { - name: null, - email: null, - passwordUpdate: errors.passwordUpdate, - }, - status: 400, - }, - { status: 400 }, - ) - } + // Validate password + if (errors.passwordUpdate) { + return data( + { + errors: { + name: null, + email: null, + passwordUpdate: errors.passwordUpdate, + }, + status: 400, + }, + { status: 400 }, + ); + } - const user = await verifyLogin(email, passwordUpdate) - // If password is invalid - if (!user) { - return data( - { - errors: { - name: null, - email: null, - passwordUpdate: 'Invalid password', - }, - }, - { status: 400 }, - ) - } + const user = await verifyLogin(email, passwordUpdate); + // If password is invalid + if (!user) { + return data( + { + errors: { + name: null, + email: null, + passwordUpdate: "Invalid password", + }, + }, + { status: 400 }, + ); + } - // Update locale and name - await updateUserlocale(email, language) - await updateUserName(email, name) + // Update locale and name + await updateUserlocale(email, language); + await updateUserName(email, name); - // Return success response - return data( - { - errors: { - name: null, - email: null, - passwordUpdate: null, - }, - }, - { status: 200 }, - ) + // Return success response + return data( + { + errors: { + name: null, + email: null, + passwordUpdate: null, + }, + }, + { status: 200 }, + ); } //***************************************************** export default function EditUserProfilePage() { - const userData = useLoaderData() // Load user data - const actionData = useActionData() - const [lang, setLang] = useState(userData?.language || 'en_US') - const [name, setName] = useState(userData?.name || '') - const passwordUpdRef = useRef(null) // For password update focus - const { toast } = useToast() - const { t } = useTranslation('settings') + const userData = useLoaderData(); // Load user data + const actionData = useActionData(); + const [lang, setLang] = useState(userData?.language || "en_US"); + const [name, setName] = useState(userData?.name || ""); + const passwordUpdRef = useRef(null); // For password update focus + const { toast } = useToast(); - useEffect(() => { - // Handle invalid password update error - if (actionData && actionData?.errors?.passwordUpdate) { - toast({ - title: t('invalid_password'), - variant: 'destructive', - }) - passwordUpdRef.current?.focus() - } - // Show success toast if profile updated - if (actionData && !actionData?.errors?.passwordUpdate) { - toast({ - title: t('profile_successfully_updated'), - variant: 'success', - }) - } - }, [actionData, toast]) + useEffect(() => { + // Handle invalid password update error + if (actionData && actionData?.errors?.passwordUpdate) { + toast({ + title: "Invalid password", + variant: "destructive", + }); + passwordUpdRef.current?.focus(); + } + // Show success toast if profile updated + if (actionData && !actionData?.errors?.passwordUpdate) { + toast({ + title: "Profile successfully updated.", + variant: "success", + }); + } + }, [actionData, toast]); - return ( -
- - - {t("account_information")} - {t("update_basic_details")} - - -
- - setName(e.target.value)} - /> -
-
- - -
-
- - -
-
- - -
-
- - - -
-
- ) + return ( +
+ + + Account Information + Update your basic account details. + + +
+ + setName(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ ); } diff --git a/app/routes/settings.delete.tsx b/app/routes/settings.delete.tsx index f28eaa1b..559b67ca 100644 --- a/app/routes/settings.delete.tsx +++ b/app/routes/settings.delete.tsx @@ -1,132 +1,117 @@ -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useEffect, useRef, useState } from "react"; +import { Form, useActionData , data, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router"; +import invariant from "tiny-invariant"; +import { Button } from "~/components/ui/button"; import { - Form, - useActionData, - data, - redirect, - type ActionFunctionArgs, - type LoaderFunctionArgs, -} from 'react-router' -import invariant from 'tiny-invariant' -import { Button } from '~/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '~/components/ui/card' -import { Input } from '~/components/ui/input' -import { Label } from '~/components/ui/label' -import { useToast } from '~/components/ui/use-toast' -import { - deleteUserByEmail, - getUserByEmail, - verifyLogin, -} from '~/models/user.server' -import { getUserEmail, getUserId } from '~/utils/session.server' + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { useToast } from "~/components/ui/use-toast"; +import { deleteUserByEmail, getUserByEmail } from "~/models/user.server"; +import { getUser, getUserEmail, getUserId } from "~/utils/session.server"; //***************************************************** export async function loader({ request }: LoaderFunctionArgs) { - //* if user is not logged in, redirect to home - const userId = await getUserId(request) - if (!userId) return redirect('/') + //* if user is not logged in, redirect to home + const userId = await getUserId(request); + if (!userId) return redirect("/"); - //* get user email - const userEmail = await getUserEmail(request) - //* load user data - invariant(userEmail, `Email not found!`) - const userData = await getUserByEmail(userEmail) - return userData + //* get user email + const userEmail = await getUserEmail(request); + //* load user data + invariant(userEmail, `Email not found!`); + const userData = await getUserByEmail(userEmail); + return userData; } //***************************************************** export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData() - - // get all values of the form - const { intent, ...values } = Object.fromEntries(formData) - const { passwordDelete } = values - - invariant(typeof passwordDelete === 'string', 'password must be a string') - - const userEmail = await getUserEmail(request) - invariant(userEmail, `Email not found!`) + const formData = await request.formData(); + // log all values of the form + const { intent, ...values } = Object.fromEntries(formData); + const { passwordDelete } = values; - const user = await verifyLogin(userEmail, passwordDelete) + invariant(typeof passwordDelete === "string", "password must be a string"); - //* check if entered password is invalid - if (!user) { - return data( - { - errors: { - passwordDelete: 'Invalid password', - }, - intent: intent, - }, - { status: 400 }, - ) - } + const user = await getUser(request); + //* if entered password is invalid + if (!user) { + return data( + { + errors: { + passwordDelete: "Invalid password", + }, + intent: intent, + }, + { status: 400 }, + ); + } - //* delete user - await deleteUserByEmail(user.email) + //* delete user + await deleteUserByEmail(user.email); - return redirect('') + return redirect(""); } export default function EditUserProfilePage() { - const actionData = useActionData() - const [passwordDelVal, setPasswordVal] = useState('') //* to enable delete account button - //* To focus when an error occured - const passwordDelRef = useRef(null) - //* toast - const { toast } = useToast() - const { t } = useTranslation('settings') + const actionData = useActionData(); + const [passwordDelVal, setPasswordVal] = useState(""); //* to enable delete account button + //* To focus when an error occured + const passwordDelRef = useRef(null); + //* toast + const { toast } = useToast(); - useEffect(() => { - //* when password is not correct - if (actionData && actionData?.errors?.passwordDelete) { - toast({ - title: t('invalid_password'), - variant: 'destructive', - }) - passwordDelRef.current?.focus() - } - }, [actionData, toast]) + useEffect(() => { + //* when password is not correct + if (actionData && actionData?.errors?.passwordDelete) { + toast({ + title: "Invalid password", + variant: "destructive", + }); + passwordDelRef.current?.focus(); + } + }, [actionData, toast]); - return ( -
- - - {t('delete_account')} - {t('delete_account_description')} - - -
- - setPasswordVal(e.target.value)} - /> -
- -
-
-
- ) + return ( +
+ + + Delete Account + + Deleting your account will permanently remove all of your data from + our servers. This action cannot be undone. + + + +
+ + setPasswordVal(e.target.value)} + /> +
+ +
+
+
+ ); } diff --git a/app/routes/settings.password.tsx b/app/routes/settings.password.tsx index 86450e75..3c32400f 100644 --- a/app/routes/settings.password.tsx +++ b/app/routes/settings.password.tsx @@ -1,214 +1,215 @@ -import { useEffect, useRef } from 'react' -import { useTranslation } from 'react-i18next' +import { useEffect, useRef } from "react"; import { - type ActionFunctionArgs, - type LoaderFunctionArgs, - data, - redirect, - Form, - useActionData, -} from 'react-router' -import invariant from 'tiny-invariant' -import { useToast } from '@/components/ui/use-toast' -import ErrorMessage from '~/components/error-message' -import { Button } from '~/components/ui/button' + type ActionFunctionArgs, + type LoaderFunctionArgs, + data, + redirect, + Form, + useActionData, +} from "react-router"; +import invariant from "tiny-invariant"; +import { useToast } from "@/components/ui/use-toast"; +import ErrorMessage from "~/components/error-message"; +import { Button } from "~/components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '~/components/ui/card' -import { Input } from '~/components/ui/input' -import { Label } from '~/components/ui/label' -import { updateUserPassword, verifyLogin } from '~/models/user.server' -import { validatePassLength, validatePassType } from '~/utils' -import { getUserEmail, getUserId } from '~/utils/session.server' + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { updateUserPassword, verifyLogin } from "~/models/user.server"; +import { validatePassLength, validatePassType } from "~/utils"; +import { getUserEmail, getUserId } from "~/utils/session.server"; //***************************************************** export async function loader({ request }: LoaderFunctionArgs) { - //* if user is not logged in, redirect to home - const userId = await getUserId(request) - if (!userId) return redirect('/') - return {} + //* if user is not logged in, redirect to home + const userId = await getUserId(request); + if (!userId) return redirect("/"); + return {}; } //***************************************************** export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData() - const intent = formData.get('intent') - const currPass = formData.get('currentPassword') - const newPass = formData.get('newPassword') - const confirmPass = formData.get('newPasswordConfirm') - const passwordsList = [currPass, newPass, confirmPass] - - //* when cancel button is clicked - if (intent === 'cancel') { - return redirect('/account/settings') - } - - //* validate passwords type - const checkPasswordsType = validatePassType(passwordsList) - if (!checkPasswordsType.isValid) { - return data( - { - success: false, - message: 'Password is required.', - }, - { status: 400 }, - ) - } - - //* validate passwords lenghts - const validatePasswordsLength = validatePassLength(passwordsList) - if (!validatePasswordsLength.isValid) { - return data( - { - success: false, - message: 'Password must be at least 8 characters long.', - }, - { status: 400 }, - ) - } - - //* get user email - const userEmail = await getUserEmail(request) - invariant(userEmail, `Email not found!`) - - //* validate password - if (typeof currPass !== 'string' || currPass.length === 0) { - return data( - { - success: false, - message: 'Current password is required.', - }, - { status: 400 }, - ) - } - - //* check both new passwords match - if (newPass !== confirmPass) { - return data( - { - success: false, - message: 'New passwords do not match.', - }, - { status: 400 }, - ) - } - - //* check user password is correct - const user = await verifyLogin(userEmail, currPass) - - if (!user) { - return data( - { success: false, message: 'Current password is incorrect.' }, - { status: 400 }, - ) - } - - //* get user ID - const userId = await getUserId(request) - invariant(userId, `userId not found!`) - - if (typeof newPass !== 'string' || newPass.length === 0) { - return data( - { success: false, message: 'Password is required.' }, - { status: 400 }, - ) - } - - //* update user password - await updateUserPassword(userId, newPass) - - return data({ success: true, message: 'Password updated successfully.' }) - //* logout - // return logout({ request: request, redirectTo: "/explore" }); + const formData = await request.formData(); + const intent = formData.get("intent"); + const currPass = formData.get("currentPassword"); + const newPass = formData.get("newPassword"); + const confirmPass = formData.get("newPasswordConfirm"); + const passwordsList = [currPass, newPass, confirmPass]; + + //* when cancel button is clicked + if (intent === "cancel") { + return redirect("/account/settings"); + } + + //* validate passwords type + const checkPasswordsType = validatePassType(passwordsList); + if (!checkPasswordsType.isValid) { + return data( + { + success: false, + message: "Password is required.", + }, + { status: 400 }, + ); + } + + //* validate passwords lenghts + const validatePasswordsLength = validatePassLength(passwordsList); + if (!validatePasswordsLength.isValid) { + return data( + { + success: false, + message: "Password must be at least 8 characters long.", + }, + { status: 400 }, + ); + } + + //* get user email + const userEmail = await getUserEmail(request); + invariant(userEmail, `Email not found!`); + + //* validate password + if (typeof currPass !== "string" || currPass.length === 0) { + return data( + { + success: false, + message: "Current password is required.", + }, + { status: 400 }, + ); + } + + //* check both new passwords match + if (newPass !== confirmPass) { + return data( + { + success: false, + message: "New passwords do not match.", + }, + { status: 400 }, + ); + } + + //* check user password is correct + const user = await verifyLogin(userEmail, currPass); + + if (!user) { + return data( + { success: false, message: "Current password is incorrect." }, + { status: 400 }, + ); + } + + //* get user ID + const userId = await getUserId(request); + invariant(userId, `userId not found!`); + + if (typeof newPass !== "string" || newPass.length === 0) { + return data( + { success: false, message: "Password is required." }, + { status: 400 }, + ); + } + + //* update user password + await updateUserPassword(userId, newPass); + + return data({ success: true, message: "Password updated successfully." }); + //* logout + // return logout({ request: request, redirectTo: "/explore" }); } //********************************** export default function ChangePaasswordPage() { - const actionData = useActionData() - - let $form = useRef(null) - const currPassRef = useRef(null) - const newPassRef = useRef(null) - const confirmPassRef = useRef(null) - - //* toast - const { toast } = useToast() - const { t } = useTranslation('settings') - - useEffect(() => { - if (actionData) { - $form.current?.reset() - if (actionData.success) { - toast({ title: actionData.message, variant: 'success' }) - currPassRef.current?.focus() - } else { - toast({ - title: actionData.message, - variant: 'destructive', - description: t('try_again'), - }) - } - } - }, [actionData, toast]) - - return ( -
- - - {t('update_password')} - {t('update_password_description')} - - -
- - -
-
- - -
-
- - -
-
- - - -
-
- ) + const actionData = useActionData(); + + let $form = useRef(null); + const currPassRef = useRef(null); + const newPassRef = useRef(null); + const confirmPassRef = useRef(null); + + //* toast + const { toast } = useToast(); + + useEffect(() => { + if (actionData) { + $form.current?.reset(); + if (actionData.success) { + toast({ title: actionData.message, variant: "success" }); + currPassRef.current?.focus(); + } else { + toast({ + title: actionData.message, + variant: "destructive", + description: "Please try again.", + }); + } + } + }, [actionData, toast]); + + return ( +
+ + + Update Password + + Enter your current password and a new password to update your + account password. + + + +
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ ); } export function ErrorBoundary() { - return ( -
- -
- ) + return ( +
+ +
+ ); } diff --git a/app/routes/settings.profile.photo.tsx b/app/routes/settings.profile.photo.tsx index 06e71079..44f1785b 100644 --- a/app/routes/settings.profile.photo.tsx +++ b/app/routes/settings.profile.photo.tsx @@ -3,7 +3,6 @@ import { getFieldsetConstraint, parse } from "@conform-to/zod"; import { type FileUpload, parseFormData } from "@mjackson/form-data-parser"; import { eq } from "drizzle-orm"; import { useState } from "react"; -import { useTranslation } from "react-i18next"; import { type LoaderFunctionArgs, type ActionFunctionArgs, @@ -135,8 +134,6 @@ export default function PhotoChooserModal() { shouldRevalidate: "onBlur", }); - const { t } = useTranslation("settings"); - const dismissModal = () => navigate("..", { preventScrollReset: true }); return ( @@ -146,7 +143,7 @@ export default function PhotoChooserModal() { className="dark:bg-dark-background dark:text-dark-text dark:border-dark-border" > - {t("profile_photo")} + Profile photo
{newImageSrc ? (
- - + +
) : (
- {t("change")} + Change
)} {/* */} diff --git a/app/routes/settings.profile.tsx b/app/routes/settings.profile.tsx index 6b158d8f..7d996278 100644 --- a/app/routes/settings.profile.tsx +++ b/app/routes/settings.profile.tsx @@ -1,6 +1,5 @@ import { InfoIcon } from "lucide-react"; import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; import { Form, Link, Outlet, useActionData, // useFormAction, // useNavigation, @@ -74,8 +73,6 @@ export default function EditUserProfilePage() { const [username, setUsername] = useState(data.profile.username); const [isPublic, setIsPublic] = useState(data.profile.public || false); - const { t } = useTranslation("settings"); - //* toast const { toast } = useToast(); @@ -83,14 +80,14 @@ export default function EditUserProfilePage() { if (actionData) { if (actionData.success) { toast({ - title: t("profile_updated"), - description: t("profile_updated_description"), + title: "Profile updated", + description: "Your profile has been updated successfully.", variant: "success", }); } else { toast({ - title: t("something_went_wrong"), - description: t("something_went_wrong_description"), + title: "Something went wrong.", + description: "Please try again later.", variant: "destructive", }); } @@ -101,16 +98,16 @@ export default function EditUserProfilePage() { - {t("profile_settings")} + Profile Settings - {t("profile_settings_description")} + This is how others see your profile.
- + @@ -118,7 +115,9 @@ export default function EditUserProfilePage() {

- {t("if_public")} + { + "If your profile is public, this is how people will see you." + }

@@ -130,14 +129,14 @@ export default function EditUserProfilePage() { type="text" id="username" name="username" - placeholder={t("enter_username")} + placeholder="Enter your new username" defaultValue={username} onChange={(e) => setUsername(e.target.value)} />
- + @@ -145,11 +144,11 @@ export default function EditUserProfilePage() {

- {t("if_activated_public_1")}{" "} + If activated, others will be able to see your public{" "} - {t("if_activated_public_2")} + profile - {t("if_activated_public_3")} + .

@@ -178,8 +177,8 @@ export default function EditUserProfilePage() { preventScrollReset to="photo" className="border-night-700 bg-night-500 absolute -right-3 top-3 flex h-4 w-4 items-center justify-center rounded-full border-4 p-5 pointer-events-auto" - title={t("change_profile_photo")} - aria-label={t("change_profile_photo")} + title="Change profile photo" + aria-label="Change profile photo" > ✎ @@ -194,7 +193,7 @@ export default function EditUserProfilePage() { isPublic === data.profile.public } > - {t("save_changes")} + Save changes diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 142ffb48..cfe8630e 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -1,4 +1,3 @@ -import { useTranslation } from "react-i18next"; import { Link, Outlet, useLocation } from "react-router"; import ErrorMessage from "~/components/error-message"; import { NavBar } from "~/components/nav-bar"; @@ -8,9 +7,6 @@ export default function SettingsLayoutPage() { const location = useLocation(); // get current tab from the URL const currentTab = location.pathname.split("/")[2]; - - const { t } = useTranslation("settings"); - return (
@@ -26,25 +22,25 @@ export default function SettingsLayoutPage() { className="data-[state=active]:text-light-green" value="profile" > - {t("public_profile")} + Public Profile - {t("account")} + Account - {t("password")} + Password - {t("delete_account")} + Delete Account diff --git a/app/schema/enum.ts b/app/schema/enum.ts index ba7cd39e..da4eb094 100644 --- a/app/schema/enum.ts +++ b/app/schema/enum.ts @@ -1,45 +1,35 @@ -import { pgEnum } from 'drizzle-orm/pg-core' -import { z } from 'zod' +import { pgEnum } from "drizzle-orm/pg-core"; +import { z } from "zod"; // Enum for device exposure types -export const DeviceExposureEnum = pgEnum('exposure', [ - 'indoor', - 'outdoor', - 'mobile', - 'unknown', -]) +export const DeviceExposureEnum = pgEnum("exposure", [ + "indoor", + "outdoor", + "mobile", + "unknown", +]); // Zod schema for validating device exposure types -export const DeviceExposureZodEnum = z.enum(DeviceExposureEnum.enumValues) +export const DeviceExposureZodEnum = z.enum(DeviceExposureEnum.enumValues); // Type inferred from the Zod schema for device exposure types -export type DeviceExposureType = z.infer +export type DeviceExposureType = z.infer; // Enum for device status types -export const DeviceStatusEnum = pgEnum('status', ['active', 'inactive', 'old']) +export const DeviceStatusEnum = pgEnum("status", ["active", "inactive", "old"]); // Zod schema for validating device status types -export const DeviceStatusZodEnum = z.enum(DeviceStatusEnum.enumValues) +export const DeviceStatusZodEnum = z.enum(DeviceStatusEnum.enumValues); // Type inferred from the Zod schema for device status types -export type DeviceStatusType = z.infer +export type DeviceStatusType = z.infer; // Enum for device model types -export const DeviceModelEnum = pgEnum('model', [ - 'homeV2Lora', - 'homeV2Ethernet', - 'homeV2Wifi', - 'homeEthernet', - 'homeWifi', - 'homeEthernetFeinstaub', - 'homeWifiFeinstaub', - 'luftdaten_sds011', - 'luftdaten_sds011_dht11', - 'luftdaten_sds011_dht22', - 'luftdaten_sds011_bmp180', - 'luftdaten_sds011_bme280', - 'hackair_home_v2', - 'senseBox:Edu', - 'luftdaten.info', - 'Custom', -]) +export const DeviceModelEnum = pgEnum("model", [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "senseBox:Edu", + "luftdaten.info", + "Custom", +]); diff --git a/app/schema/sensor.ts b/app/schema/sensor.ts index 949ccfe9..6c823aa1 100644 --- a/app/schema/sensor.ts +++ b/app/schema/sensor.ts @@ -10,7 +10,7 @@ import { device } from "./device"; import { DeviceStatusEnum } from "./enum"; import { type Measurement } from "./measurement"; -export function generateHexId(): string { +function generateHexId(): string { return randomBytes(12).toString('hex'); } @@ -34,7 +34,6 @@ export const sensor = pgTable("sensor", { title: text("title"), unit: text("unit"), sensorType: text("sensor_type"), - icon: text("icon"), status: DeviceStatusEnum("status").default("inactive"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), diff --git a/app/utils/addon-definitions.ts b/app/utils/addon-definitions.ts deleted file mode 100644 index 04ca03d9..00000000 --- a/app/utils/addon-definitions.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const addonDefinitions: Record< - string, - { title: string; unit: string; sensorType: string; icon?: string }[] -> = { - feinstaub: [ - { - title: 'PM10', - unit: 'µg/m³', - sensorType: 'SDS 011', - icon: 'particulate_matter', - }, - { - title: 'PM2.5', - unit: 'µg/m³', - sensorType: 'SDS 011', - icon: 'particulate_matter', - }, - ], -} diff --git a/app/utils/csv.ts b/app/utils/csv.ts index 92d6aa5f..cb5e5a62 100644 --- a/app/utils/csv.ts +++ b/app/utils/csv.ts @@ -4,26 +4,14 @@ * @param data The data as an array of arbitrary data rows * @param dataSelectors Selectors that picks data out of a data row and converts it into a string. * Order should be the same as for the headers. - * @returns + * @returns */ -export const convertToCsv = ( - headers: string[], - data: DataRow[], - dataSelectors: ((row: DataRow) => string)[], - delimiter = ',', -) => { - const rows: string[] = data.map((dataRow) => - headers.map((_, i) => dataSelectors[i](dataRow)).join(delimiter), - ) +export const convertToCsv = (headers: string[], data: DataRow[], + dataSelectors: ((row: DataRow) => string)[], delimiter = ",") => { - return [headers.join(delimiter), ...rows].join('\n') -} + const rows: string[] = data.map(dataRow => + headers.map((_, i) => dataSelectors[i](dataRow)).join(delimiter) + ) -export function escapeCSVValue(value: any, delimiter: string): string { - if (value === null || value === undefined) return '' - const str = String(value) - if (str.includes(delimiter) || str.includes('"') || str.includes('\n')) { - return `"${str.replace(/"/g, '""')}"` - } - return str -} + return [headers.join(delimiter), ...rows].join("\n") +} \ No newline at end of file diff --git a/app/utils/model-definitions.ts b/app/utils/model-definitions.ts index 77218258..33a1bd8d 100644 --- a/app/utils/model-definitions.ts +++ b/app/utils/model-definitions.ts @@ -1,122 +1,91 @@ -import { sensorDefinitions } from './sensor-definitions' +import { sensorDefinitions } from "./sensor-definitions"; // Models Definition export const modelDefinitions = { - senseBoxHomeV2: [ - sensorDefinitions.hdc1080_temperature, - sensorDefinitions.hdc1080_humidity, - sensorDefinitions.bmp280_pressure, - sensorDefinitions.tsl45315_lightintensity, - sensorDefinitions.veml6070_uvintensity, - sensorDefinitions.sds011_pm10, - sensorDefinitions.sds011_pm25, - sensorDefinitions.bme680_humidity, - sensorDefinitions.bme680_temperature, - sensorDefinitions.bme680_pressure, - sensorDefinitions.bme680_voc, - sensorDefinitions.smt50_soilmoisture, - sensorDefinitions.smt50_soiltemperature, - sensorDefinitions.soundlevelmeter, - sensorDefinitions.windspeed, - sensorDefinitions.scd30_co2, - sensorDefinitions.dps310_pressure, - sensorDefinitions.sps30_pm1, - sensorDefinitions.sps30_pm4, - sensorDefinitions.sps30_pm10, - sensorDefinitions.sps30_pm25, - ], - 'senseBox:Edu': [ - sensorDefinitions.hdc1080_temperature, - sensorDefinitions.hdc1080_humidity, - sensorDefinitions.bmp280_pressure, - sensorDefinitions.tsl45315_lightintensity, - sensorDefinitions.veml6070_uvintensity, - sensorDefinitions.sds011_pm10, - sensorDefinitions.sds011_pm25, - sensorDefinitions.bme680_humidity, - sensorDefinitions.bme680_temperature, - sensorDefinitions.bme680_pressure, - sensorDefinitions.bme680_voc, - sensorDefinitions.smt50_soilmoisture, - sensorDefinitions.smt50_soiltemperature, - sensorDefinitions.soundlevelmeter, - sensorDefinitions.windspeed, - sensorDefinitions.scd30_co2, - sensorDefinitions.dps310_pressure, - sensorDefinitions.sps30_pm1, - sensorDefinitions.sps30_pm4, - sensorDefinitions.sps30_pm10, - sensorDefinitions.sps30_pm25, - ], - 'luftdaten.info': [ - sensorDefinitions.pms1003_pm01, - sensorDefinitions.pms1003_pm10, - sensorDefinitions.pms1003_pm25, - sensorDefinitions.pms3003_pm01, - sensorDefinitions.pms3003_pm10, - sensorDefinitions.pms3003_pm25, - sensorDefinitions.pms5003_pm01, - sensorDefinitions.pms5003_pm10, - sensorDefinitions.pms5003_pm25, - sensorDefinitions.pms7003_pm01, - sensorDefinitions.pms7003_pm10, - sensorDefinitions.pms7003_pm25, - sensorDefinitions.sds011_pm10, - sensorDefinitions.sds011_pm25, - sensorDefinitions.sps30_pm1, - sensorDefinitions.sps30_pm4, - sensorDefinitions.sps30_pm10, - sensorDefinitions.sps30_pm25, - sensorDefinitions.sht3x_humidity, - sensorDefinitions.sht3x_temperature, - sensorDefinitions.bmp180_temperature, - sensorDefinitions.bmp180_pressure_pa, - sensorDefinitions.bmp180_pressure_hpa, - sensorDefinitions.bme280_humidity, - sensorDefinitions.bme280_temperature, - sensorDefinitions.bme280_pressure_pa, - sensorDefinitions.bme280_pressure_hpa, - sensorDefinitions.dht11_humidity, - sensorDefinitions.dht11_temperature, - sensorDefinitions.dht22_humidity, - sensorDefinitions.dht22_temperature, - ], - homeEthernet: [ - sensorDefinitions.hdc1008_temperature, - sensorDefinitions.hdc1008_humidity, - sensorDefinitions.bmp280_pressure, - sensorDefinitions.tsl45315_lightintensity, - sensorDefinitions.veml6070_uvintensity, - ], - - homeWifi: [ - sensorDefinitions.hdc1008_temperature, - sensorDefinitions.hdc1008_humidity, - sensorDefinitions.bmp280_pressure, - sensorDefinitions.tsl45315_lightintensity, - sensorDefinitions.veml6070_uvintensity, - ], - homeEthernetFeinstaub: [ - sensorDefinitions.hdc1008_temperature, - sensorDefinitions.hdc1008_humidity, - sensorDefinitions.bmp280_pressure, - sensorDefinitions.tsl45315_lightintensity, - sensorDefinitions.veml6070_uvintensity, - sensorDefinitions.sds011_pm10, - sensorDefinitions.sds011_pm25, - ], - homeWifiFeinstaub: [ - sensorDefinitions.hdc1008_temperature, - sensorDefinitions.hdc1008_humidity, - sensorDefinitions.bmp280_pressure, - sensorDefinitions.tsl45315_lightintensity, - sensorDefinitions.veml6070_uvintensity, - sensorDefinitions.sds011_pm10, - sensorDefinitions.sds011_pm25, - ], -} + senseBoxHomeV2: [ + sensorDefinitions.hdc1080_temperature, + sensorDefinitions.hdc1080_humidity, + sensorDefinitions.bmp280_pressure, + sensorDefinitions.tsl45315_lightintensity, + sensorDefinitions.veml6070_uvintensity, + sensorDefinitions.sds011_pm10, + sensorDefinitions.sds011_pm25, + sensorDefinitions.bme680_humidity, + sensorDefinitions.bme680_temperature, + sensorDefinitions.bme680_pressure, + sensorDefinitions.bme680_voc, + sensorDefinitions.smt50_soilmoisture, + sensorDefinitions.smt50_soiltemperature, + sensorDefinitions.soundlevelmeter, + sensorDefinitions.windspeed, + sensorDefinitions.scd30_co2, + sensorDefinitions.dps310_pressure, + sensorDefinitions.sps30_pm1, + sensorDefinitions.sps30_pm4, + sensorDefinitions.sps30_pm10, + sensorDefinitions.sps30_pm25, + ], + "senseBox:Edu": [ + sensorDefinitions.hdc1080_temperature, + sensorDefinitions.hdc1080_humidity, + sensorDefinitions.bmp280_pressure, + sensorDefinitions.tsl45315_lightintensity, + sensorDefinitions.veml6070_uvintensity, + sensorDefinitions.sds011_pm10, + sensorDefinitions.sds011_pm25, + sensorDefinitions.bme680_humidity, + sensorDefinitions.bme680_temperature, + sensorDefinitions.bme680_pressure, + sensorDefinitions.bme680_voc, + sensorDefinitions.smt50_soilmoisture, + sensorDefinitions.smt50_soiltemperature, + sensorDefinitions.soundlevelmeter, + sensorDefinitions.windspeed, + sensorDefinitions.scd30_co2, + sensorDefinitions.dps310_pressure, + sensorDefinitions.sps30_pm1, + sensorDefinitions.sps30_pm4, + sensorDefinitions.sps30_pm10, + sensorDefinitions.sps30_pm25, + ], + "luftdaten.info": [ + sensorDefinitions.pms1003_pm01, + sensorDefinitions.pms1003_pm10, + sensorDefinitions.pms1003_pm25, + sensorDefinitions.pms3003_pm01, + sensorDefinitions.pms3003_pm10, + sensorDefinitions.pms3003_pm25, + sensorDefinitions.pms5003_pm01, + sensorDefinitions.pms5003_pm10, + sensorDefinitions.pms5003_pm25, + sensorDefinitions.pms7003_pm01, + sensorDefinitions.pms7003_pm10, + sensorDefinitions.pms7003_pm25, + sensorDefinitions.sds011_pm10, + sensorDefinitions.sds011_pm25, + sensorDefinitions.sps30_pm1, + sensorDefinitions.sps30_pm4, + sensorDefinitions.sps30_pm10, + sensorDefinitions.sps30_pm25, + sensorDefinitions.sht3x_humidity, + sensorDefinitions.sht3x_temperature, + sensorDefinitions.bmp180_temperature, + sensorDefinitions.bmp180_pressure_pa, + sensorDefinitions.bmp180_pressure_hpa, + sensorDefinitions.bme280_humidity, + sensorDefinitions.bme280_temperature, + sensorDefinitions.bme280_pressure_pa, + sensorDefinitions.bme280_pressure_hpa, + sensorDefinitions.dht11_humidity, + sensorDefinitions.dht11_temperature, + sensorDefinitions.dht22_humidity, + sensorDefinitions.dht22_temperature, + ], + // if custom, return all sensors + Custom: Object.values(sensorDefinitions), +}; // Exporting models export const getSensorsForModel = (model: keyof typeof modelDefinitions) => { - return modelDefinitions[model] || null -} + return modelDefinitions[model] || null; +}; diff --git a/drizzle/0023_red_chameleon.sql b/drizzle/0023_red_chameleon.sql deleted file mode 100644 index afc6b666..00000000 --- a/drizzle/0023_red_chameleon.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TYPE "public"."model" ADD VALUE 'homeEthernet' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'homeWifi' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'homeEthernetFeinstaub' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'homeWifiFeinstaub' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_dht11' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_dht22' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_bmp180' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_bme280' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'hackair_home_v2' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TABLE "sensor" ADD COLUMN "icon" text; \ No newline at end of file diff --git a/drizzle/0024_first_kitty_pryde.sql b/drizzle/0024_first_kitty_pryde.sql deleted file mode 100644 index afc6b666..00000000 --- a/drizzle/0024_first_kitty_pryde.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TYPE "public"."model" ADD VALUE 'homeEthernet' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'homeWifi' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'homeEthernetFeinstaub' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'homeWifiFeinstaub' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_dht11' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_dht22' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_bmp180' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_bme280' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TYPE "public"."model" ADD VALUE 'hackair_home_v2' BEFORE 'senseBox:Edu';--> statement-breakpoint -ALTER TABLE "sensor" ADD COLUMN "icon" text; \ No newline at end of file diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json index 2437bf9a..bb478b93 100644 --- a/drizzle/meta/0022_snapshot.json +++ b/drizzle/meta/0022_snapshot.json @@ -1,1187 +1,1263 @@ { - "id": "95fc2b5e-a6d7-426d-bfd8-7c5238f5722b", - "prevId": "85481101-dd0d-4e15-9158-11971b8ba509", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.device": { - "name": "device", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tags": { - "name": "tags", - "type": "text[]", - "primaryKey": false, - "notNull": false, - "default": "ARRAY[]::text[]" - }, - "link": { - "name": "link", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "use_auth": { - "name": "use_auth", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "exposure": { - "name": "exposure", - "type": "exposure", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "status", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'inactive'" - }, - "model": { - "name": "model", - "type": "model", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "public": { - "name": "public", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expires_at": { - "name": "expires_at", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "latitude": { - "name": "latitude", - "type": "double precision", - "primaryKey": false, - "notNull": true - }, - "longitude": { - "name": "longitude", - "type": "double precision", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sensor_wiki_model": { - "name": "sensor_wiki_model", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.device_to_location": { - "name": "device_to_location", - "schema": "", - "columns": { - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "location_id": { - "name": "location_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "time": { - "name": "time", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "device_to_location_device_id_device_id_fk": { - "name": "device_to_location_device_id_device_id_fk", - "tableFrom": "device_to_location", - "tableTo": "device", - "columnsFrom": ["device_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - }, - "device_to_location_location_id_location_id_fk": { - "name": "device_to_location_location_id_location_id_fk", - "tableFrom": "device_to_location", - "tableTo": "location", - "columnsFrom": ["location_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "device_to_location_device_id_location_id_time_pk": { - "name": "device_to_location_device_id_location_id_time_pk", - "columns": ["device_id", "location_id", "time"] - } - }, - "uniqueConstraints": { - "device_to_location_device_id_location_id_time_unique": { - "name": "device_to_location_device_id_location_id_time_unique", - "nullsNotDistinct": false, - "columns": ["device_id", "location_id", "time"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.measurement": { - "name": "measurement", - "schema": "", - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "value": { - "name": "value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "location_id": { - "name": "location_id", - "type": "bigint", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "measurement_location_id_location_id_fk": { - "name": "measurement_location_id_location_id_fk", - "tableFrom": "measurement", - "tableTo": "location", - "columnsFrom": ["location_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "measurement_sensor_id_time_unique": { - "name": "measurement_sensor_id_time_unique", - "nullsNotDistinct": false, - "columns": ["sensor_id", "time"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.password": { - "name": "password", - "schema": "", - "columns": { - "hash": { - "name": "hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "password_user_id_user_id_fk": { - "name": "password_user_id_user_id_fk", - "tableFrom": "password", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.password_reset_request": { - "name": "password_reset_request", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "password_reset_request_user_id_user_id_fk": { - "name": "password_reset_request_user_id_user_id_fk", - "tableFrom": "password_reset_request", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "password_reset_request_user_id_unique": { - "name": "password_reset_request_user_id_unique", - "nullsNotDistinct": false, - "columns": ["user_id"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.profile": { - "name": "profile", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "public": { - "name": "public", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "profile_user_id_user_id_fk": { - "name": "profile_user_id_user_id_fk", - "tableFrom": "profile", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "profile_username_unique": { - "name": "profile_username_unique", - "nullsNotDistinct": false, - "columns": ["username"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.profile_image": { - "name": "profile_image", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "alt_text": { - "name": "alt_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "content_type": { - "name": "content_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "blob": { - "name": "blob", - "type": "bytea", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "profile_id": { - "name": "profile_id", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "profile_image_profile_id_profile_id_fk": { - "name": "profile_image_profile_id_profile_id_fk", - "tableFrom": "profile_image", - "tableTo": "profile", - "columnsFrom": ["profile_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sensor": { - "name": "sensor", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "unit": { - "name": "unit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sensor_type": { - "name": "sensor_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "status", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'inactive'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sensor_wiki_type": { - "name": "sensor_wiki_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sensor_wiki_phenomenon": { - "name": "sensor_wiki_phenomenon", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sensor_wiki_unit": { - "name": "sensor_wiki_unit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "lastMeasurement": { - "name": "lastMeasurement", - "type": "json", - "primaryKey": false, - "notNull": false - }, - "data": { - "name": "data", - "type": "json", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "sensor_device_id_device_id_fk": { - "name": "sensor_device_id_device_id_fk", - "tableFrom": "sensor", - "tableTo": "device", - "columnsFrom": ["device_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "unconfirmed_email": { - "name": "unconfirmed_email", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'user'" - }, - "language": { - "name": "language", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'en_US'" - }, - "email_is_confirmed": { - "name": "email_is_confirmed", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "email_confirmation_token": { - "name": "email_confirmation_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - }, - "user_unconfirmed_email_unique": { - "name": "user_unconfirmed_email_unique", - "nullsNotDistinct": false, - "columns": ["unconfirmed_email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.location": { - "name": "location", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "location": { - "name": "location", - "type": "geometry(point)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "location_index": { - "name": "location_index", - "columns": [ - { - "expression": "location", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gist", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "location_location_unique": { - "name": "location_location_unique", - "nullsNotDistinct": false, - "columns": ["location"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.log_entry": { - "name": "log_entry", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "public": { - "name": "public", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.refresh_token": { - "name": "refresh_token", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "refresh_token_user_id_user_id_fk": { - "name": "refresh_token_user_id_user_id_fk", - "tableFrom": "refresh_token", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.token_revocation": { - "name": "token_revocation", - "schema": "", - "columns": { - "hash": { - "name": "hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "json", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.claim": { - "name": "claim", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "box_id": { - "name": "box_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "claim_expires_at_idx": { - "name": "claim_expires_at_idx", - "columns": [ - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "claim_box_id_device_id_fk": { - "name": "claim_box_id_device_id_fk", - "tableFrom": "claim", - "tableTo": "device", - "columnsFrom": ["box_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_box_id": { - "name": "unique_box_id", - "nullsNotDistinct": false, - "columns": ["box_id"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.access_token": { - "name": "access_token", - "schema": "", - "columns": { - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "access_token_device_id_device_id_fk": { - "name": "access_token_device_id_device_id_fk", - "tableFrom": "access_token", - "tableTo": "device", - "columnsFrom": ["device_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.exposure": { - "name": "exposure", - "schema": "public", - "values": ["indoor", "outdoor", "mobile", "unknown"] - }, - "public.model": { - "name": "model", - "schema": "public", - "values": [ - "homeV2Lora", - "homeV2Ethernet", - "homeV2Wifi", - "senseBox:Edu", - "luftdaten.info", - "Custom" - ] - }, - "public.status": { - "name": "status", - "schema": "public", - "values": ["active", "inactive", "old"] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": { - "public.measurement_10min": { - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "name": "measurement_10min", - "schema": "public", - "isExisting": true, - "materialized": true - }, - "public.measurement_1day": { - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "name": "measurement_1day", - "schema": "public", - "isExisting": true, - "materialized": true - }, - "public.measurement_1hour": { - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "name": "measurement_1hour", - "schema": "public", - "isExisting": true, - "materialized": true - }, - "public.measurement_1month": { - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "name": "measurement_1month", - "schema": "public", - "isExisting": true, - "materialized": true - }, - "public.measurement_1year": { - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "name": "measurement_1year", - "schema": "public", - "isExisting": true, - "materialized": true - } - }, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} + "id": "95fc2b5e-a6d7-426d-bfd8-7c5238f5722b", + "prevId": "85481101-dd0d-4e15-9158-11971b8ba509", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_location": { + "name": "device_to_location", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_location_device_id_device_id_fk": { + "name": "device_to_location_device_id_device_id_fk", + "tableFrom": "device_to_location", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_to_location_location_id_location_id_fk": { + "name": "device_to_location_location_id_location_id_fk", + "tableFrom": "device_to_location", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_location_device_id_location_id_time_pk": { + "name": "device_to_location_device_id_location_id_time_pk", + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "uniqueConstraints": { + "device_to_location_device_id_location_id_time_unique": { + "name": "device_to_location_device_id_location_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.measurement": { + "name": "measurement", + "schema": "", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "measurement_location_id_location_id_fk": { + "name": "measurement_location_id_location_id_fk", + "tableFrom": "measurement", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_request": { + "name": "password_reset_request", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_request_user_id_user_id_fk": { + "name": "password_reset_request_user_id_user_id_fk", + "tableFrom": "password_reset_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_request_user_id_unique": { + "name": "password_reset_request_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_unconfirmed_email_unique": { + "name": "user_unconfirmed_email_unique", + "nullsNotDistinct": false, + "columns": [ + "unconfirmed_email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "location_index": { + "name": "location_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "location_location_unique": { + "name": "location_location_unique", + "nullsNotDistinct": false, + "columns": [ + "location" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entry": { + "name": "log_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_revocation": { + "name": "token_revocation", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "box_id": { + "name": "box_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "claim_expires_at_idx": { + "name": "claim_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "claim_box_id_device_id_fk": { + "name": "claim_box_id_device_id_fk", + "tableFrom": "claim", + "tableTo": "device", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_box_id": { + "name": "unique_box_id", + "nullsNotDistinct": false, + "columns": [ + "box_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "senseBox:Edu", + "luftdaten.info", + "Custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.measurement_10min": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_10min", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1day": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1day", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1hour": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1hour", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1month": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1month", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1year": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1year", + "schema": "public", + "isExisting": true, + "materialized": true + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0023_snapshot.json b/drizzle/meta/0023_snapshot.json index 87b9b2ae..7b257b01 100644 --- a/drizzle/meta/0023_snapshot.json +++ b/drizzle/meta/0023_snapshot.json @@ -1,1187 +1,1263 @@ { - "id": "b7903c96-4a1f-498b-abb4-07815b2d42d8", - "prevId": "95fc2b5e-a6d7-426d-bfd8-7c5238f5722b", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.device": { - "name": "device", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tags": { - "name": "tags", - "type": "text[]", - "primaryKey": false, - "notNull": false, - "default": "ARRAY[]::text[]" - }, - "link": { - "name": "link", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "use_auth": { - "name": "use_auth", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "exposure": { - "name": "exposure", - "type": "exposure", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "status", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'inactive'" - }, - "model": { - "name": "model", - "type": "model", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "public": { - "name": "public", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expires_at": { - "name": "expires_at", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "latitude": { - "name": "latitude", - "type": "double precision", - "primaryKey": false, - "notNull": true - }, - "longitude": { - "name": "longitude", - "type": "double precision", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sensor_wiki_model": { - "name": "sensor_wiki_model", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.device_to_location": { - "name": "device_to_location", - "schema": "", - "columns": { - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "location_id": { - "name": "location_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "time": { - "name": "time", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "device_to_location_device_id_device_id_fk": { - "name": "device_to_location_device_id_device_id_fk", - "tableFrom": "device_to_location", - "columnsFrom": ["device_id"], - "tableTo": "device", - "columnsTo": ["id"], - "onUpdate": "cascade", - "onDelete": "cascade" - }, - "device_to_location_location_id_location_id_fk": { - "name": "device_to_location_location_id_location_id_fk", - "tableFrom": "device_to_location", - "columnsFrom": ["location_id"], - "tableTo": "location", - "columnsTo": ["id"], - "onUpdate": "no action", - "onDelete": "no action" - } - }, - "compositePrimaryKeys": { - "device_to_location_device_id_location_id_time_pk": { - "name": "device_to_location_device_id_location_id_time_pk", - "columns": ["device_id", "location_id", "time"] - } - }, - "uniqueConstraints": { - "device_to_location_device_id_location_id_time_unique": { - "name": "device_to_location_device_id_location_id_time_unique", - "columns": ["device_id", "location_id", "time"], - "nullsNotDistinct": false - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.measurement": { - "name": "measurement", - "schema": "", - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "value": { - "name": "value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "location_id": { - "name": "location_id", - "type": "bigint", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "measurement_location_id_location_id_fk": { - "name": "measurement_location_id_location_id_fk", - "tableFrom": "measurement", - "columnsFrom": ["location_id"], - "tableTo": "location", - "columnsTo": ["id"], - "onUpdate": "no action", - "onDelete": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "measurement_sensor_id_time_unique": { - "name": "measurement_sensor_id_time_unique", - "columns": ["sensor_id", "time"], - "nullsNotDistinct": false - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.password": { - "name": "password", - "schema": "", - "columns": { - "hash": { - "name": "hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "password_user_id_user_id_fk": { - "name": "password_user_id_user_id_fk", - "tableFrom": "password", - "columnsFrom": ["user_id"], - "tableTo": "user", - "columnsTo": ["id"], - "onUpdate": "cascade", - "onDelete": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.password_reset_request": { - "name": "password_reset_request", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "password_reset_request_user_id_user_id_fk": { - "name": "password_reset_request_user_id_user_id_fk", - "tableFrom": "password_reset_request", - "columnsFrom": ["user_id"], - "tableTo": "user", - "columnsTo": ["id"], - "onUpdate": "no action", - "onDelete": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "password_reset_request_user_id_unique": { - "name": "password_reset_request_user_id_unique", - "columns": ["user_id"], - "nullsNotDistinct": false - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.profile": { - "name": "profile", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "public": { - "name": "public", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "profile_user_id_user_id_fk": { - "name": "profile_user_id_user_id_fk", - "tableFrom": "profile", - "columnsFrom": ["user_id"], - "tableTo": "user", - "columnsTo": ["id"], - "onUpdate": "cascade", - "onDelete": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "profile_username_unique": { - "name": "profile_username_unique", - "columns": ["username"], - "nullsNotDistinct": false - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.profile_image": { - "name": "profile_image", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "alt_text": { - "name": "alt_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "content_type": { - "name": "content_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "blob": { - "name": "blob", - "type": "bytea", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "profile_id": { - "name": "profile_id", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "profile_image_profile_id_profile_id_fk": { - "name": "profile_image_profile_id_profile_id_fk", - "tableFrom": "profile_image", - "columnsFrom": ["profile_id"], - "tableTo": "profile", - "columnsTo": ["id"], - "onUpdate": "cascade", - "onDelete": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sensor": { - "name": "sensor", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "unit": { - "name": "unit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sensor_type": { - "name": "sensor_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "status", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'inactive'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sensor_wiki_type": { - "name": "sensor_wiki_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sensor_wiki_phenomenon": { - "name": "sensor_wiki_phenomenon", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sensor_wiki_unit": { - "name": "sensor_wiki_unit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "lastMeasurement": { - "name": "lastMeasurement", - "type": "json", - "primaryKey": false, - "notNull": false - }, - "data": { - "name": "data", - "type": "json", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "sensor_device_id_device_id_fk": { - "name": "sensor_device_id_device_id_fk", - "tableFrom": "sensor", - "columnsFrom": ["device_id"], - "tableTo": "device", - "columnsTo": ["id"], - "onUpdate": "no action", - "onDelete": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "unconfirmed_email": { - "name": "unconfirmed_email", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'user'" - }, - "language": { - "name": "language", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'en_US'" - }, - "email_is_confirmed": { - "name": "email_is_confirmed", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "email_confirmation_token": { - "name": "email_confirmation_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "columns": ["email"], - "nullsNotDistinct": false - }, - "user_unconfirmed_email_unique": { - "name": "user_unconfirmed_email_unique", - "columns": ["unconfirmed_email"], - "nullsNotDistinct": false - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.location": { - "name": "location", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "location": { - "name": "location", - "type": "geometry(point)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "location_index": { - "name": "location_index", - "columns": [ - { - "expression": "location", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "with": {}, - "method": "gist", - "concurrently": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "location_location_unique": { - "name": "location_location_unique", - "columns": ["location"], - "nullsNotDistinct": false - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.log_entry": { - "name": "log_entry", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "public": { - "name": "public", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.refresh_token": { - "name": "refresh_token", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "refresh_token_user_id_user_id_fk": { - "name": "refresh_token_user_id_user_id_fk", - "tableFrom": "refresh_token", - "columnsFrom": ["user_id"], - "tableTo": "user", - "columnsTo": ["id"], - "onUpdate": "no action", - "onDelete": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.token_revocation": { - "name": "token_revocation", - "schema": "", - "columns": { - "hash": { - "name": "hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "json", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.claim": { - "name": "claim", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "box_id": { - "name": "box_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "claim_expires_at_idx": { - "name": "claim_expires_at_idx", - "columns": [ - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "with": {}, - "method": "btree", - "concurrently": false - } - }, - "foreignKeys": { - "claim_box_id_device_id_fk": { - "name": "claim_box_id_device_id_fk", - "tableFrom": "claim", - "columnsFrom": ["box_id"], - "tableTo": "device", - "columnsTo": ["id"], - "onUpdate": "no action", - "onDelete": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_box_id": { - "name": "unique_box_id", - "columns": ["box_id"], - "nullsNotDistinct": false - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.access_token": { - "name": "access_token", - "schema": "", - "columns": { - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "access_token_device_id_device_id_fk": { - "name": "access_token_device_id_device_id_fk", - "tableFrom": "access_token", - "columnsFrom": ["device_id"], - "tableTo": "device", - "columnsTo": ["id"], - "onUpdate": "no action", - "onDelete": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.exposure": { - "name": "exposure", - "schema": "public", - "values": ["indoor", "outdoor", "mobile", "unknown"] - }, - "public.model": { - "name": "model", - "schema": "public", - "values": [ - "homeV2Lora", - "homeV2Ethernet", - "homeV2Wifi", - "senseBox:Edu", - "luftdaten.info", - "Custom" - ] - }, - "public.status": { - "name": "status", - "schema": "public", - "values": ["active", "inactive", "old"] - } - }, - "schemas": {}, - "views": { - "public.measurement_10min": { - "name": "measurement_10min", - "schema": "public", - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "materialized": true, - "isExisting": true - }, - "public.measurement_1day": { - "name": "measurement_1day", - "schema": "public", - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "materialized": true, - "isExisting": true - }, - "public.measurement_1hour": { - "name": "measurement_1hour", - "schema": "public", - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "materialized": true, - "isExisting": true - }, - "public.measurement_1month": { - "name": "measurement_1month", - "schema": "public", - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "materialized": true, - "isExisting": true - }, - "public.measurement_1year": { - "name": "measurement_1year", - "schema": "public", - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "materialized": true, - "isExisting": true - } - }, - "sequences": {}, - "roles": {}, - "policies": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} + "id": "b7903c96-4a1f-498b-abb4-07815b2d42d8", + "prevId": "95fc2b5e-a6d7-426d-bfd8-7c5238f5722b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_location": { + "name": "device_to_location", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_location_device_id_device_id_fk": { + "name": "device_to_location_device_id_device_id_fk", + "tableFrom": "device_to_location", + "columnsFrom": [ + "device_id" + ], + "tableTo": "device", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + }, + "device_to_location_location_id_location_id_fk": { + "name": "device_to_location_location_id_location_id_fk", + "tableFrom": "device_to_location", + "columnsFrom": [ + "location_id" + ], + "tableTo": "location", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_location_device_id_location_id_time_pk": { + "name": "device_to_location_device_id_location_id_time_pk", + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "uniqueConstraints": { + "device_to_location_device_id_location_id_time_unique": { + "name": "device_to_location_device_id_location_id_time_unique", + "columns": [ + "device_id", + "location_id", + "time" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.measurement": { + "name": "measurement", + "schema": "", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "measurement_location_id_location_id_fk": { + "name": "measurement_location_id_location_id_fk", + "tableFrom": "measurement", + "columnsFrom": [ + "location_id" + ], + "tableTo": "location", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "columns": [ + "sensor_id", + "time" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_request": { + "name": "password_reset_request", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_request_user_id_user_id_fk": { + "name": "password_reset_request_user_id_user_id_fk", + "tableFrom": "password_reset_request", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_request_user_id_unique": { + "name": "password_reset_request_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "columnsFrom": [ + "profile_id" + ], + "tableTo": "profile", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "columnsFrom": [ + "device_id" + ], + "tableTo": "device", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + }, + "user_unconfirmed_email_unique": { + "name": "user_unconfirmed_email_unique", + "columns": [ + "unconfirmed_email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "location_index": { + "name": "location_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gist", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "location_location_unique": { + "name": "location_location_unique", + "columns": [ + "location" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entry": { + "name": "log_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_revocation": { + "name": "token_revocation", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "box_id": { + "name": "box_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "claim_expires_at_idx": { + "name": "claim_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "claim_box_id_device_id_fk": { + "name": "claim_box_id_device_id_fk", + "tableFrom": "claim", + "columnsFrom": [ + "box_id" + ], + "tableTo": "device", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_box_id": { + "name": "unique_box_id", + "columns": [ + "box_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "columnsFrom": [ + "device_id" + ], + "tableTo": "device", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "senseBox:Edu", + "luftdaten.info", + "Custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "views": { + "public.measurement_10min": { + "name": "measurement_10min", + "schema": "public", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "materialized": true, + "isExisting": true + }, + "public.measurement_1day": { + "name": "measurement_1day", + "schema": "public", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "materialized": true, + "isExisting": true + }, + "public.measurement_1hour": { + "name": "measurement_1hour", + "schema": "public", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "materialized": true, + "isExisting": true + }, + "public.measurement_1month": { + "name": "measurement_1month", + "schema": "public", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "materialized": true, + "isExisting": true + }, + "public.measurement_1year": { + "name": "measurement_1year", + "schema": "public", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "materialized": true, + "isExisting": true + } + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json deleted file mode 100644 index a4eb1392..00000000 --- a/drizzle/meta/0024_snapshot.json +++ /dev/null @@ -1,1279 +0,0 @@ -{ - "id": "be35aeec-1a4d-449f-bbe9-b290b6a79093", - "prevId": "b7903c96-4a1f-498b-abb4-07815b2d42d8", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.device": { - "name": "device", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tags": { - "name": "tags", - "type": "text[]", - "primaryKey": false, - "notNull": false, - "default": "ARRAY[]::text[]" - }, - "link": { - "name": "link", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "use_auth": { - "name": "use_auth", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "exposure": { - "name": "exposure", - "type": "exposure", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "status", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'inactive'" - }, - "model": { - "name": "model", - "type": "model", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "public": { - "name": "public", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expires_at": { - "name": "expires_at", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "latitude": { - "name": "latitude", - "type": "double precision", - "primaryKey": false, - "notNull": true - }, - "longitude": { - "name": "longitude", - "type": "double precision", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sensor_wiki_model": { - "name": "sensor_wiki_model", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.device_to_location": { - "name": "device_to_location", - "schema": "", - "columns": { - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "location_id": { - "name": "location_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "time": { - "name": "time", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "device_to_location_device_id_device_id_fk": { - "name": "device_to_location_device_id_device_id_fk", - "tableFrom": "device_to_location", - "tableTo": "device", - "columnsFrom": [ - "device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "cascade" - }, - "device_to_location_location_id_location_id_fk": { - "name": "device_to_location_location_id_location_id_fk", - "tableFrom": "device_to_location", - "tableTo": "location", - "columnsFrom": [ - "location_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "device_to_location_device_id_location_id_time_pk": { - "name": "device_to_location_device_id_location_id_time_pk", - "columns": [ - "device_id", - "location_id", - "time" - ] - } - }, - "uniqueConstraints": { - "device_to_location_device_id_location_id_time_unique": { - "name": "device_to_location_device_id_location_id_time_unique", - "nullsNotDistinct": false, - "columns": [ - "device_id", - "location_id", - "time" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.measurement": { - "name": "measurement", - "schema": "", - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "value": { - "name": "value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "location_id": { - "name": "location_id", - "type": "bigint", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "measurement_location_id_location_id_fk": { - "name": "measurement_location_id_location_id_fk", - "tableFrom": "measurement", - "tableTo": "location", - "columnsFrom": [ - "location_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "measurement_sensor_id_time_unique": { - "name": "measurement_sensor_id_time_unique", - "nullsNotDistinct": false, - "columns": [ - "sensor_id", - "time" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.password": { - "name": "password", - "schema": "", - "columns": { - "hash": { - "name": "hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "password_user_id_user_id_fk": { - "name": "password_user_id_user_id_fk", - "tableFrom": "password", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.password_reset_request": { - "name": "password_reset_request", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "password_reset_request_user_id_user_id_fk": { - "name": "password_reset_request_user_id_user_id_fk", - "tableFrom": "password_reset_request", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "password_reset_request_user_id_unique": { - "name": "password_reset_request_user_id_unique", - "nullsNotDistinct": false, - "columns": [ - "user_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.profile": { - "name": "profile", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "public": { - "name": "public", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "profile_user_id_user_id_fk": { - "name": "profile_user_id_user_id_fk", - "tableFrom": "profile", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "profile_username_unique": { - "name": "profile_username_unique", - "nullsNotDistinct": false, - "columns": [ - "username" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.profile_image": { - "name": "profile_image", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "alt_text": { - "name": "alt_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "content_type": { - "name": "content_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "blob": { - "name": "blob", - "type": "bytea", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "profile_id": { - "name": "profile_id", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "profile_image_profile_id_profile_id_fk": { - "name": "profile_image_profile_id_profile_id_fk", - "tableFrom": "profile_image", - "tableTo": "profile", - "columnsFrom": [ - "profile_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sensor": { - "name": "sensor", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "unit": { - "name": "unit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sensor_type": { - "name": "sensor_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "icon": { - "name": "icon", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "status", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'inactive'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sensor_wiki_type": { - "name": "sensor_wiki_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sensor_wiki_phenomenon": { - "name": "sensor_wiki_phenomenon", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sensor_wiki_unit": { - "name": "sensor_wiki_unit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "lastMeasurement": { - "name": "lastMeasurement", - "type": "json", - "primaryKey": false, - "notNull": false - }, - "data": { - "name": "data", - "type": "json", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "sensor_device_id_device_id_fk": { - "name": "sensor_device_id_device_id_fk", - "tableFrom": "sensor", - "tableTo": "device", - "columnsFrom": [ - "device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "unconfirmed_email": { - "name": "unconfirmed_email", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'user'" - }, - "language": { - "name": "language", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'en_US'" - }, - "email_is_confirmed": { - "name": "email_is_confirmed", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "email_confirmation_token": { - "name": "email_confirmation_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - }, - "user_unconfirmed_email_unique": { - "name": "user_unconfirmed_email_unique", - "nullsNotDistinct": false, - "columns": [ - "unconfirmed_email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.location": { - "name": "location", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "location": { - "name": "location", - "type": "geometry(point)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "location_index": { - "name": "location_index", - "columns": [ - { - "expression": "location", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gist", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "location_location_unique": { - "name": "location_location_unique", - "nullsNotDistinct": false, - "columns": [ - "location" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.log_entry": { - "name": "log_entry", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "public": { - "name": "public", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.refresh_token": { - "name": "refresh_token", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "refresh_token_user_id_user_id_fk": { - "name": "refresh_token_user_id_user_id_fk", - "tableFrom": "refresh_token", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.token_revocation": { - "name": "token_revocation", - "schema": "", - "columns": { - "hash": { - "name": "hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "json", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.claim": { - "name": "claim", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "box_id": { - "name": "box_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "claim_expires_at_idx": { - "name": "claim_expires_at_idx", - "columns": [ - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "claim_box_id_device_id_fk": { - "name": "claim_box_id_device_id_fk", - "tableFrom": "claim", - "tableTo": "device", - "columnsFrom": [ - "box_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_box_id": { - "name": "unique_box_id", - "nullsNotDistinct": false, - "columns": [ - "box_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.access_token": { - "name": "access_token", - "schema": "", - "columns": { - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "access_token_device_id_device_id_fk": { - "name": "access_token_device_id_device_id_fk", - "tableFrom": "access_token", - "tableTo": "device", - "columnsFrom": [ - "device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.exposure": { - "name": "exposure", - "schema": "public", - "values": [ - "indoor", - "outdoor", - "mobile", - "unknown" - ] - }, - "public.model": { - "name": "model", - "schema": "public", - "values": [ - "homeV2Lora", - "homeV2Ethernet", - "homeV2Wifi", - "homeEthernet", - "homeWifi", - "homeEthernetFeinstaub", - "homeWifiFeinstaub", - "luftdaten_sds011", - "luftdaten_sds011_dht11", - "luftdaten_sds011_dht22", - "luftdaten_sds011_bmp180", - "luftdaten_sds011_bme280", - "hackair_home_v2", - "senseBox:Edu", - "luftdaten.info", - "Custom" - ] - }, - "public.status": { - "name": "status", - "schema": "public", - "values": [ - "active", - "inactive", - "old" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": { - "public.measurement_10min": { - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "name": "measurement_10min", - "schema": "public", - "isExisting": true, - "materialized": true - }, - "public.measurement_1day": { - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "name": "measurement_1day", - "schema": "public", - "isExisting": true, - "materialized": true - }, - "public.measurement_1hour": { - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "name": "measurement_1hour", - "schema": "public", - "isExisting": true, - "materialized": true - }, - "public.measurement_1month": { - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "name": "measurement_1month", - "schema": "public", - "isExisting": true, - "materialized": true - }, - "public.measurement_1year": { - "columns": { - "sensor_id": { - "name": "sensor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "time": { - "name": "time", - "type": "timestamp (3) with time zone", - "primaryKey": false, - "notNull": false - }, - "avg_value": { - "name": "avg_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "total_values": { - "name": "total_values", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "min_value": { - "name": "min_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "max_value": { - "name": "max_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "name": "measurement_1year", - "schema": "public", - "isExisting": true, - "materialized": true - } - }, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0bbba872..2e48cd63 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -169,13 +169,6 @@ "when": 1765380754120, "tag": "0023_check_location", "breakpoints": true - }, - { - "idx": 24, - "version": "7", - "when": 1767972133643, - "tag": "0024_first_kitty_pryde", - "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 40e72e98..6f0f6fa1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -154,7 +154,6 @@ "@types/node": "^22.15.35", "@types/node-fetch": "^2.6.13", "@types/nodemailer": "^7.0.1", - "@types/pg": "^8.15.6", "@types/react": "19.1.8", "@types/react-dom": "19.2.2", "@types/source-map-support": "^0.5.10", @@ -8998,17 +8997,6 @@ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "license": "MIT" }, - "node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", - "dev": true, - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, "node_modules/@types/prismjs": { "version": "1.26.5", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", diff --git a/package.json b/package.json index 499d06fb..b2ef34e1 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,6 @@ "@types/node": "^22.15.35", "@types/node-fetch": "^2.6.13", "@types/nodemailer": "^7.0.1", - "@types/pg": "^8.15.6", "@types/react": "19.1.8", "@types/react-dom": "19.2.2", "@types/source-map-support": "^0.5.10", diff --git a/public/locales/de/connect.json b/public/locales/de/connect.json deleted file mode 100644 index 616214f2..00000000 --- a/public/locales/de/connect.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "title": "Verbinde jedes Gerät", - "description": "Wir unterstützen vorkonfigurierte Geräte einiger Hersteller, aber du kannst jederzeit deine eigene Hardware und Sensoren registrieren.", - "senseBox": "senseBox", - "hackAIR": "hackAIR", - "Sensor.Community": "Sensor.Community", - "Custom": "Speziell" -} \ No newline at end of file diff --git a/public/locales/de/data-table.json b/public/locales/de/data-table.json deleted file mode 100644 index 62b43ebb..00000000 --- a/public/locales/de/data-table.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "filter_names": "Namen filtern...", - "no_results": "Keine Ergebnisse.", - "rows_per_page": "Zeilen pro Seite", - "page": "Seite", - "of": "von", - "first_page": "Erste Seite", - "previous_page": "Vorige Seite", - "next_page": "Nächste Seite", - "last_page": "Letzte Seite", - - "sensebox_id": "Sensebox ID", - "actions": "Aktionen", - "name": "Name", - "exposure": "Exposition", - "model": "Modell", - "overview": "Überblick", - "show_on_map": "Auf der Karte anzeigen", - "edit": "Bearbeiten", - "data_upload": "Daten hochladen", - "support": "Unterstützung", - "copy_id": "ID kopieren" -} diff --git a/public/locales/de/features.json b/public/locales/de/features.json deleted file mode 100644 index 2a11d491..00000000 --- a/public/locales/de/features.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "features": "Features", - "description": "Die openSenseMap-Plattform hat viel zu bieten, was die Auffindbarkeit und den Austausch von Umwelt- und Sensordaten einfach macht.", - "dataAggregation": "Erfassung von Daten", - "noDataRetention": "Keine Datenrückhaltung", - "dataPublished": "Veröffentlichung der Daten als ODbL", - "discoverDevices": "Entdecke Geräte", - "compareDevices": "Vergleiche Geräte", - "downloadOptions": "Downloadoptionen", - "httpRestApi": "HTTP REST API" -} \ No newline at end of file diff --git a/public/locales/de/footer.json b/public/locales/de/footer.json deleted file mode 100644 index ce1b776d..00000000 --- a/public/locales/de/footer.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "rightsReserved": "Alle Rechte vorbehalten.", - "imprint": "Impressum", - "dataProtection": "Datenschutz", - "instagram": "Instagram Seite", - "twitter": "Twitter Seite", - "github": "Github Konto" -} \ No newline at end of file diff --git a/public/locales/de/header.json b/public/locales/de/header.json deleted file mode 100644 index 67faca2f..00000000 --- a/public/locales/de/header.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Explore": "Erkunden", - "Features": "Features", - "Tools": "Werkzeuge", - "Use Cases": "Anwendungen", - "Partners": "Partner", - "Sponsor": "Spenden" -} \ No newline at end of file diff --git a/public/locales/de/integrations.json b/public/locales/de/integrations.json deleted file mode 100644 index d19b98a7..00000000 --- a/public/locales/de/integrations.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Integrationen", - "description": "Wir unterstützen verschiedene Protokolle und bieten spezifische Integrationen für diese an.", - "HTTP API": "HTTP API", - "MQTT": "MQTT", - "TTN": "TTN v3 (LoRa WAN)" -} \ No newline at end of file diff --git a/public/locales/de/login.json b/public/locales/de/login.json index ae795f9e..f22c19a3 100644 --- a/public/locales/de/login.json +++ b/public/locales/de/login.json @@ -5,25 +5,5 @@ "transition_label": "Wird eingeloggt...", "remember_label": "Eingeloggt bleiben", "no_account_label": "Noch kein Konto?", - "register_label": "Registrieren", - - "welcome_back": "Willkommen zurück", - "sign_in": "Melde dich an", - "forgot_password": "Passwort vergessen?", - "sign_in_button": "Anmelden", - "example_placeholder": "beispiel@opensensemap.org", - - "request_sent": "Anfrage gesendet", - "request_sent_description": "Eine E-Mail mit einer Anleitung zum Zurücksetzen deines Passworts wurde versendet. Bitte prüfe deine E-Mails.", - "back_to_login": "Zurück zur Anmeldung", - "forgot_your_password": "Hast du dein Passwort vergessen?", - "reset_password": "Passwort per Mail zurücksetzen", - "remember_password": "Erinnerst du dich an dein Passwort?", - - "Email is invalid": "Ungültige E-Mail-Adresse", - "An error occurred. Please try again later.": "Ein Fehler ist aufgetreten. Bitte versuche es später erneut.", - "Password is required": "Passwort benötigt", - "Password is too short": "Passwort ist zu kurz", - "Invalid email or password": "Falsche E-Mail-Adresse oder falsches Passwort" + "register_label": "Registrieren" } - diff --git a/public/locales/de/menu.json b/public/locales/de/menu.json index d23623b8..b5dede06 100644 --- a/public/locales/de/menu.json +++ b/public/locales/de/menu.json @@ -1,7 +1,6 @@ { "title": "Wilkommen", "subtitle": "Bitte loggen Sie sich ein, um mehr Inhalte zu sehen.", - "explore_label": "Erkunden", "profile_label": "Profil", "settings_label": "Einstellungen", "my_devices_label": "Meine Geräte", diff --git a/public/locales/de/navbar.json b/public/locales/de/navbar.json index 8b768b0a..161b877e 100644 --- a/public/locales/de/navbar.json +++ b/public/locales/de/navbar.json @@ -14,12 +14,5 @@ "pointintime_label": "Zeitpunkt", "pointintime_description": "Zeige einen historischen Zeitpunkt auf der Karte an.", "timeperiod_label": "Zeitraum", - "timeperiod_description": "Erforsche die Entwicklung der Phänomene in einem bestimmten Zeitfenster.", - - "Settings": "Einstellungen", - "Profile": "Profil", - "Account": "Konto", - "Password": "Passwort", - "Delete": "Löschen", - "Photo": "Profilbild", + "timeperiod_description": "Erforsche die Entwicklung der Phänomene in einem bestimmten Zeitfenster." } diff --git a/public/locales/de/partners.json b/public/locales/de/partners.json deleted file mode 100644 index 577cb079..00000000 --- a/public/locales/de/partners.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "Partners": "Partner", - "hosted": "gehostet von" -} \ No newline at end of file diff --git a/public/locales/de/pricing-plans.json b/public/locales/de/pricing-plans.json deleted file mode 100644 index b9007f61..00000000 --- a/public/locales/de/pricing-plans.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "Pricing": "Preise", - "kidding": "Wir machen Witze, openSenseMap ist kostenlos und open-source.", - "contribution": "Du kannst trotzdem deinen Beitrag leisten.", - "star": "Gib uns einen Stern", - "sponsor": "Werde ein Sponsor" -} \ No newline at end of file diff --git a/public/locales/de/profile.json b/public/locales/de/profile.json deleted file mode 100644 index 72fd48d5..00000000 --- a/public/locales/de/profile.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "user_since": "Benutzer*in seit", - "locale": "de", - "devices": "Geräte", - "sensors": "Sensoren", - "measurements": "Messungen", - "badges": "Badges" -} \ No newline at end of file diff --git a/public/locales/de/settings.json b/public/locales/de/settings.json deleted file mode 100644 index 05990253..00000000 --- a/public/locales/de/settings.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "public_profile": "Öffentliches Profil", - "account": "Konto", - "password": "Passwort", - "delete_account": "Konto löschen", - - "profile_updated": "Profil aktualisiert", - "profile_updated_description": "Dein Profil wurde erfolgreich aktualisiert.", - "something_went_wrong": "Etwas ist schief gelaufen.", - "something_went_wrong_description": "Bitte versuche es später erneut.", - "profile_settings": "Profileinstellungen", - "profile_settings_description": "Das ist, wie andere dein Profil sehen.", - "username": "Benutzername", - "if_public": "Wenn dein Profil öffentlich ist, ist das wie andere Menschen dich sehen werden.", - "enter_username": "Gib deinen Benutzernamen ein", - "if_activated_public_1": "Wenn aktiviert, werden andere dein öffentliches", - "if_activated_public_2": "Profil", - "if_activated_public_3": " sehen können.", - "change_profile_photo": "Profilbild ändern", - "save_changes": "Änderungen speichern", - - "profile_photo": "Profilbild", - "save_photo": "Profilbild speichern", - "reset": "Zurücksetzen", - "change": "Ändern", - - "invalid_password": "Falsches Passwort", - "profile_successfully_updated": "Profil efolgreich aktualisiert", - "account_information": "Konto Informationen", - "update_basic_details": "Aktualisiere deine grundlegenden Konto Details.", - "name": "Name", - "enter_name": "Gib deinen Namen ein", - "email": "E-Mail", - "enter_email": "Gib deine E-Mail-Adresse ein", - "language": "Sprache", - "select_language": "Wähle die Sprache aus", - "confirm_password": "Bestätige dein Passwort", - "enter_current_password": "Gib dein aktuelles Passwort ein", - - "try_again": "Versuche es nochmal.", - "update_password": "Aktualisiere dein Passwort", - "update_password_description": "Gib dein aktuelles und ein neues Passwort ein, um dein Konto Passwort zu aktualisieren.", - "current_password": "Aktuelles Passwort", - "new_password": "Neues Passwort", - "enter_new_password": "Gib dein neues Passwort ein", - "confirm_new_password": "Bestätige dein neues Passwort", - "password_required": "Passwort benötigt.", - "password_length": "Das Passwort muss mindestens 8 Zeichen lang sein.", - "email_not_found": "E-Mail-Adresse nicht gefunden!", - "current_password_required": "Aktuelles Passwort benötigt.", - "new_passwords_do_not_match": "Die neuen Passwörter stimmen nicht überein.", - "current_password_incorret": "Das aktuelle Passwort ist falsch.", - "password_updated_successfully": "Das Passwort wurde erfolgreich aktualisiert.", - - "delete_account_description": "Wenn du dein Konto löschst, werden alle deine Daten permanent von unseren Servern gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", - "enter_password": "Gib dein Passwort ein" -} \ No newline at end of file diff --git a/public/locales/de/tools.json b/public/locales/de/tools.json deleted file mode 100644 index 8cfad0ae..00000000 --- a/public/locales/de/tools.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "notSupported": "Dein Browser unterstützt den Video Tag nicht." -} \ No newline at end of file diff --git a/public/locales/en/connect.json b/public/locales/en/connect.json deleted file mode 100644 index 31bdad0c..00000000 --- a/public/locales/en/connect.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "title": "Connect any device", - "description": "We support preconfigured devices by some vendors but you can always register your custom hardware and sensor setup.", - "senseBox": "senseBox", - "hackAIR": "hackAIR", - "Sensor.Community": "Sensor.Community", - "Custom": "Custom" -} \ No newline at end of file diff --git a/public/locales/en/data-table.json b/public/locales/en/data-table.json deleted file mode 100644 index 6d0f6423..00000000 --- a/public/locales/en/data-table.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "filter_names": "Filter names...", - "no_results": "No results.", - "rows_per_page": "Rows per page", - "page": "Page", - "of": "of", - "first_page": "First page", - "previous_page": "Previous page", - "next_page": "Next page", - "last_page": "Last page", - - "sensebox_id": "Sensebox ID", - "actions": "Actions", - "name": "Name", - "exposure": "Exposure", - "model": "Model", - "overview": "Overview", - "show_on_map": "Show on map", - "edit": "Edit", - "data_upload": "Data upload", - "support": "Support", - "copy_id": "Copy ID" -} \ No newline at end of file diff --git a/public/locales/en/features.json b/public/locales/en/features.json deleted file mode 100644 index 2566d52f..00000000 --- a/public/locales/en/features.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "features": "Features", - "description": "The openSenseMap platform has a lot to offer that makes discoverability and sharing of environmental and sensor data easy.", - "dataAggregation": "Data aggregation", - "noDataRetention": "No data retention", - "dataPublished": "Data published as ODbL", - "discoverDevices": "Discover devices", - "compareDevices": "Compare devices", - "downloadOptions": "Download options", - "httpRestApi": "HTTP REST API" -} \ No newline at end of file diff --git a/public/locales/en/footer.json b/public/locales/en/footer.json deleted file mode 100644 index a50507b5..00000000 --- a/public/locales/en/footer.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "rightsReserved": "All Rights Reserved.", - "imprint": "Imprint", - "dataProtection": "Data protection", - "instagram": "Instagram page", - "twitter": "Twitter page", - "github": "GitHub account" -} \ No newline at end of file diff --git a/public/locales/en/header.json b/public/locales/en/header.json deleted file mode 100644 index 8f050c06..00000000 --- a/public/locales/en/header.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Explore": "Explore", - "Features": "Features", - "Tools": "Tools", - "Use Cases": "Use Cases", - "Partners": "Partners", - "Sponsor": "Sponsor" -} \ No newline at end of file diff --git a/public/locales/en/integrations.json b/public/locales/en/integrations.json deleted file mode 100644 index a641475e..00000000 --- a/public/locales/en/integrations.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Integrations", - "description": "We support different data communication protocols and offer specific integrations for them.", - "HTTP API": "HTTP API", - "MQTT": "MQTT", - "TTN": "TTN v3 (LoRa WAN)" -} \ No newline at end of file diff --git a/public/locales/en/login.json b/public/locales/en/login.json index 4a70945f..ed697335 100644 --- a/public/locales/en/login.json +++ b/public/locales/en/login.json @@ -5,24 +5,5 @@ "transition_label": "Logging in...", "remember_label": "Remember me", "no_account_label": "Don't have an account?", - "register_label": "Sign up", - - "welcome_back": "Welcome back", - "sign_in": "Sign in to your account", - "forgot_password": "Forgot password?", - "sign_in_button": "Sign in", - "example_placeholder": "example@opensensemap.org", - - "request_sent": "Request Sent", - "request_sent_description": "An email with instructions to reset your password has been sent. Please check your inbox.", - "back_to_login": "Back to Login", - "forgot_your_password": "Forgot your password?", - "reset_password": "Reset password by mail", - "remember_password": "Remember your password?", - - "Email is invalid": "Email is invalid", - "An error occurred. Please try again later.": "An error occurred. Please try again later.", - "Password is required": "Password is required", - "Password is too short": "Password is too short", - "Invalid email or password": "Invalid email or password" + "register_label": "Sign up" } \ No newline at end of file diff --git a/public/locales/en/menu.json b/public/locales/en/menu.json index b23d5d9e..a5f74602 100644 --- a/public/locales/en/menu.json +++ b/public/locales/en/menu.json @@ -1,7 +1,6 @@ { "title": "Welcome", "subtitle": "Please sign in to see more content", - "explore_label": "Explore", "profile_label": "Profile", "settings_label": "Settings", "my_devices_label": "My Devices", diff --git a/public/locales/en/navbar.json b/public/locales/en/navbar.json index 257b94f6..86b8b9d5 100644 --- a/public/locales/en/navbar.json +++ b/public/locales/en/navbar.json @@ -14,12 +14,5 @@ "pointintime_label": "Point in time", "pointintime_description": "Show a historical point in time on the map.", "timeperiod_label": "Time period", - "timeperiod_description": "Explore the development of the phenomena in a specific time window.", - - "Settings": "Settings", - "Profile": "Profile", - "Account": "Account", - "Password": "Password", - "Delete": "Delete", - "Photo": "Photo" + "timeperiod_description": "Explore the development of the phenomena in a specific time window." } diff --git a/public/locales/en/partners.json b/public/locales/en/partners.json deleted file mode 100644 index 0add5c78..00000000 --- a/public/locales/en/partners.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "Partners": "Partners", - "hosted": "hosted by" -} \ No newline at end of file diff --git a/public/locales/en/pricing-plans.json b/public/locales/en/pricing-plans.json deleted file mode 100644 index b613ceb1..00000000 --- a/public/locales/en/pricing-plans.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "Pricing": "Pricing", - "kidding": "Just kidding, openSenseMap is free and open-source.", - "contribution": "You can still make your contribution.", - "star": "Give us a star", - "sponsor": "Become a sponsor" -} \ No newline at end of file diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json deleted file mode 100644 index 2378db37..00000000 --- a/public/locales/en/profile.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "user_since": "User since", - "locale": "en", - "devices": "Devices", - "sensors": "Sensors", - "measurements": "Measurements", - "badges": "Badges" -} \ No newline at end of file diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json deleted file mode 100644 index f8a3841f..00000000 --- a/public/locales/en/settings.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "public_profile": "Public Profile", - "account": "Account", - "password": "Password", - "delete_account": "Delete Account", - - "profile_updated": "Profile updated", - "profile_updated_description": "Your profile has been updated successfully.", - "something_went_wrong": "Something went wrong.", - "something_went_wrong_description": "Please try again later.", - "profile_settings": "Profile Settings", - "profile_settings_description": "This is how others see your profile.", - "username": "Username", - "if_public": "If your profile is public, this is how people will see you.", - "enter_username": "Enter your new username", - "if_activated_public_1": "If activated, others will be able to see your public", - "if_activated_public_2": "profile", - "if_activated_public_3": ".", - "change_profile_photo": "Change profile photo", - "save_changes": "Save changes", - - "profile_photo": "Profile photo", - "save_photo": "Save Photo", - "reset": "Reset", - "change": "Change", - - "invalid_password": "Invalid password", - "profile_successfully_updated": "Profile successfully updated.", - "account_information": "Account Information", - "update_basic_details": "Update your basic account details.", - "name": "Name", - "enter_name": "Enter your name", - "email": "Email", - "enter_email": "Enter your email", - "language": "Language", - "select_language": "Select language", - "confirm_password": "Confirm password", - "enter_current_password": "Enter your current password", - - "try_again": "Please try again.", - "update_password": "Update Password", - "update_password_description": "Enter your current password and a new password to update your account password.", - "current_password": "Current Password", - "new_password": "New Password", - "enter_new_password": "Enter your new password", - "confirm_new_password": "Confirm your new password", - "password_required": "Password is required.", - "password_length": "Password must be at least 8 characters long.", - "email_not_found": "Email not found!", - "current_password_required": "Current password is required.", - "new_passwords_do_not_match": "New passwords do not match.", - "current_password_incorret": "Current password is incorrect.", - "password_updated_successfully": "Password updated successfully.", - - "delete_account_description": "Deleting your account will permanently remove all of your data from our servers. This action cannot be undone.", - "enter_password": "Enter your password" -} \ No newline at end of file diff --git a/public/locales/en/tools.json b/public/locales/en/tools.json deleted file mode 100644 index bce8e27b..00000000 --- a/public/locales/en/tools.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "notSupported": "Your browser does not support the video tag." -} \ No newline at end of file diff --git a/tests/models/device.server.spec.ts b/tests/models/device.server.spec.ts index 7424f9cd..87a96216 100644 --- a/tests/models/device.server.spec.ts +++ b/tests/models/device.server.spec.ts @@ -42,7 +42,7 @@ describe('Device Model: createDevice', () => { latitude: 51.969, longitude: 7.596, exposure: 'outdoor', - // model: "homeV2Wifi", + model: 'homeV2Wifi', sensors: [ { title: 'Temperature', unit: '°C', sensorType: 'HDC1080' }, { title: 'Humidity', unit: '%', sensorType: 'HDC1080' }, @@ -101,37 +101,18 @@ describe('Device Model: createDevice', () => { expect(result.sensors).toHaveLength(0) }) - it('should create device with tags/grouptag', async () => { - const deviceData = { - name: 'Tagged Device', - latitude: 51.5, - longitude: 7.5, - exposure: 'outdoor', - // model: 'Custom', - tags: ['weather', 'city', 'test'], - sensors: [{ title: 'Temperature', unit: '°C', sensorType: 'DHT22' }], - } - - const result = await createDevice(deviceData, userId) - - createdDeviceIds.push(result.id) - expect(result).toHaveProperty('tags') - expect(Array.isArray(result.tags)).toBe(true) - expect(result.tags).toEqual(['weather', 'city', 'test']) - expect(result.sensors).toHaveLength(1) - }) - it('should create device with optional fields', async () => { const deviceData = { name: 'Full Featured Device', latitude: 51.0, longitude: 7.0, exposure: 'mobile', + model: 'homeV2Lora', description: 'A comprehensive test device', image: 'https://example.com/device.jpg', link: 'https://example.com', public: true, - tags: ['test'], + tags: [], sensors: [{ title: 'Temperature', unit: '°C', sensorType: 'SHT31' }], } @@ -143,6 +124,7 @@ describe('Device Model: createDevice', () => { expect(result).toHaveProperty('link', 'https://example.com') expect(result).toHaveProperty('public', true) expect(result).toHaveProperty('exposure', 'mobile') + expect(result).toHaveProperty('model', 'homeV2Lora') expect(result.sensors).toHaveLength(1) }) diff --git a/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts b/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts index 287dc47b..459b4329 100644 --- a/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts +++ b/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts @@ -22,7 +22,7 @@ const DEVICE_SENSOR_ID_BOX = { tags: [], latitude: 0, longitude: 0, - //model: 'luftdaten.info', + model: 'luftdaten.info', mqttEnabled: false, ttnEnabled: false, sensors: [ diff --git a/tests/routes/api.boxes.$deviceId.locations.spec.ts b/tests/routes/api.boxes.$deviceId.locations.spec.ts index dd0a77e8..42af89e6 100644 --- a/tests/routes/api.boxes.$deviceId.locations.spec.ts +++ b/tests/routes/api.boxes.$deviceId.locations.spec.ts @@ -24,6 +24,7 @@ const DEVICE_SENSOR_ID_BOX = { tags: [], latitude: 0, longitude: 0, + model: 'luftdaten.info', mqttEnabled: false, ttnEnabled: false, sensors: [ diff --git a/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts b/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts index 120e120e..4cc366d4 100644 --- a/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts +++ b/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts @@ -17,7 +17,7 @@ const DEVICE_SENSOR_ID_BOX = { tags: [], latitude: 0, longitude: 0, - //model: 'luftdaten.info', + model: 'luftdaten.info', mqttEnabled: false, ttnEnabled: false, sensors: [ diff --git a/tests/routes/api.boxes.$deviceId.sensors.spec.ts b/tests/routes/api.boxes.$deviceId.sensors.spec.ts index 82280f19..623b5d33 100644 --- a/tests/routes/api.boxes.$deviceId.sensors.spec.ts +++ b/tests/routes/api.boxes.$deviceId.sensors.spec.ts @@ -16,7 +16,7 @@ const DEVICE_SENSOR_BOX = { tags: [], latitude: 0, longitude: 0, - //model: 'luftdaten.info', + model: 'luftdaten.info', mqttEnabled: false, ttnEnabled: false, sensors: [ diff --git a/tests/routes/api.boxes.data.spec.ts b/tests/routes/api.boxes.data.spec.ts deleted file mode 100644 index e2b35427..00000000 --- a/tests/routes/api.boxes.data.spec.ts +++ /dev/null @@ -1,466 +0,0 @@ -import { eq } from 'drizzle-orm' -import { type AppLoadContext } from 'react-router' -import { generateTestUserCredentials } from 'tests/data/generate_test_user' -import { describe, it, expect, beforeAll } from 'vitest' -import { BASE_URL } from 'vitest.setup' -import { drizzleClient } from '~/db.server' -import { createToken } from '~/lib/jwt' -import { registerUser } from '~/lib/user-service.server' -import { createDevice, deleteDevice } from '~/models/device.server' -import { deleteUserByEmail } from '~/models/user.server' -import { - loader as boxesDataLoader, - action as boxesDataAction, -} from '~/routes/api.boxes.data' -import { device, measurement, sensor, type User } from '~/schema' - -const BOXES_DATA_TEST_USER = generateTestUserCredentials() - -const TEST_BOX = { - name: 'Download Box', - exposure: 'indoor' as const, - expiresAt: null, - tags: [], - latitude: 51.5, - longitude: 7.5, - model: 'luftdaten.info' as const, - mqttEnabled: false, - ttnEnabled: false, -} - -describe('openSenseMap API: /boxes/data', () => { - let jwt = '' - let user: User - let deviceId = '' - let outdoorDeviceId = '' - let sensorId = '' - - const expectedMeasurementsCount = 10 - - beforeAll(async () => { - await deleteUserByEmail(BOXES_DATA_TEST_USER.email) - - const testUser = await registerUser( - BOXES_DATA_TEST_USER.name, - BOXES_DATA_TEST_USER.email, - BOXES_DATA_TEST_USER.password, - 'en_US', - ) - user = testUser as User - const t = await createToken(user) - jwt = t.token - - const device = await createDevice(TEST_BOX, user.id) - deviceId = device.id - - const outdoorDevice = await createDevice( - { - ...TEST_BOX, - name: 'Download Box Outdoor', - exposure: 'outdoor', - }, - user.id, - ) - outdoorDeviceId = outdoorDevice.id - - const [outdoorSensor] = await drizzleClient - .insert(sensor) - .values({ - title: 'Temperatur', - unit: '°C', - sensorType: 'HDC1080', - deviceId: outdoorDevice.id, - status: 'active', - }) - .returning() - - const outdoorMeasurements = [] - for (let i = 0; i < 5; i++) { - outdoorMeasurements.push({ - sensorId: outdoorSensor.id, - time: new Date(Date.now() - i * 60000), - value: 15 + Math.random() * 5, - }) - } - - await drizzleClient.insert(measurement).values(outdoorMeasurements) - - const [createdSensor] = await drizzleClient - .insert(sensor) - .values({ - title: 'Temperatur', - unit: '°C', - sensorType: 'HDC1080', - deviceId: device.id, - status: 'active', - }) - .returning() - - sensorId = createdSensor.id - - // Create test measurements - const now = new Date() - const measurements = [] - for (let i = 0; i < expectedMeasurementsCount; i++) { - measurements.push({ - sensorId: sensorId, - time: new Date(now.getTime() - i * 60000), // 1 minute apart - value: 20 + Math.random() * 10, // 20-30°C - }) - } - - await drizzleClient.insert(measurement).values(measurements) - }) - - // --------------------------- - // CSV (default) - // --------------------------- - it('GET /boxes/data CSV', async () => { - const url = `${BASE_URL}/api/boxes/data?boxid=${deviceId}&phenomenon=Temperatur` - const req = new Request(url, { - headers: { Authorization: `Bearer ${jwt}` }, - }) - - const res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - const text = await res.text() - - expect(res.status).toBe(200) - expect(text).not.toBe('') - expect(res.headers.get('content-type')).toBe('text/csv') - - // Check that CSV has header and data rows - const lines = text.trim().split('\n') - expect(lines.length).toBeGreaterThan(1) // At least header + 1 data row - }) - - it('GET /boxes/data CSV with format=csv', async () => { - const url = `${BASE_URL}/api/boxes/data?boxid=${deviceId}&phenomenon=Temperatur&format=csv` - const req = new Request(url, { - headers: { Authorization: `Bearer ${jwt}` }, - }) - - const res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - const text = await res.text() - - expect(res.status).toBe(200) - expect(text).not.toBe('') - expect(res.headers.get('content-type')).toBe('text/csv') - }) - - // --------------------------- - // JSON - // --------------------------- - it('GET /boxes/data JSON', async () => { - const url = `${BASE_URL}/api/boxes/data?boxid=${deviceId}&phenomenon=Temperatur&format=json&columns=sensorId,value,lat,lon` - const req = new Request(url, { - headers: { Authorization: `Bearer ${jwt}` }, - }) - - const res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('application/json') - - const body = await res.json() - expect(Array.isArray(body)).toBe(true) - expect(body.length).toBeGreaterThan(0) - - for (const m of body) { - expect(m.sensorId).toBeDefined() - expect(m.value).toBeDefined() - expect(m.lat).toBeDefined() - expect(m.lon).toBeDefined() - } - }) - - // --------------------------- - // Multiple box IDs - // --------------------------- - it('GET /boxes/data CSV with multiple boxids', async () => { - const url = `${BASE_URL}/api/boxes/data?boxid=${deviceId},${deviceId}&phenomenon=Temperatur` - const req = new Request(url, { - headers: { Authorization: `Bearer ${jwt}` }, - }) - - const res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - const text = await res.text() - const lines = text.trim().split('\n').slice(1) // Skip header - - expect(res.status).toBe(200) - expect(lines).toHaveLength(expectedMeasurementsCount) - }) - - // --------------------------- - // POST CSV - // --------------------------- - it('POST /boxes/data CSV', async () => { - const req = new Request( - `${BASE_URL}/boxes/data?boxid=${deviceId}&phenomenon=Temperatur`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${jwt}`, - }, - }, - ) - - const response = await boxesDataAction({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - - const text = (await response.text()).trim() - const lines = text.split('\n').slice(1) - - expect(response.status).toBe(200) - expect(lines).toHaveLength(expectedMeasurementsCount) - }) - - // --------------------------- - // Exposure filtering - // --------------------------- - it('GET /boxes/data with exposure filter', async () => { - const from = new Date(Date.now() - 100 * 864e5).toISOString() - const to = new Date().toISOString() - - const req = new Request( - `${BASE_URL}/boxes/data/?bbox=-180,-90,180,90&phenomenon=Temperatur&exposure=indoor&columns=exposure&from-date=${from}&to-date=${to}`, - { headers: { Authorization: `Bearer ${jwt}` } }, - ) - - const res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - const text = (await res.text()).trim() - const [header, ...lines] = text.split('\n') - - expect(res.status).toBe(200) - expect(header).toBe('exposure') - expect(lines).toHaveLength(expectedMeasurementsCount) - - for (const line of lines.slice(0, -1)) { - expect(line).toBe('indoor') - } - }) - - it('GET /boxes/data with multiple exposure filters', async () => { - const from = new Date(Date.now() - 100 * 864e5).toISOString() - const to = new Date().toISOString() - - const url = - `${BASE_URL}/boxes/data/?` + - `bbox=-180,-90,180,90` + - `&phenomenon=Temperatur` + - `&exposure=indoor,outdoor` + - `&columns=exposure` + - `&from-date=${from}` + - `&to-date=${to}` - - const req = new Request(url, { - headers: { Authorization: `Bearer ${jwt}` }, - }) - - const res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('text/csv') - - const text = (await res.text()).trim() - const [header, ...lines] = text.split('\n') - - expect(header).toBe('exposure') - - expect(lines).toHaveLength(expectedMeasurementsCount + 5) - - let sawIndoor = false - let sawOutdoor = false - - for (const line of lines.slice(0, -1)) { - if (line === 'indoor') sawIndoor = true - if (line === 'outdoor') sawOutdoor = true - if (sawIndoor && sawOutdoor) break - } - - expect(sawIndoor).toBe(true) - expect(sawOutdoor).toBe(true) - }) - - // --------------------------- - // content-disposition header - // --------------------------- - it('GET /boxes/data should include content-disposition by default', async () => { - const req = new Request( - `${BASE_URL}/boxes/data/?boxid=${deviceId},${deviceId}&phenomenon=Temperatur`, - { headers: { Authorization: `Bearer ${jwt}` } }, - ) - - const res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - const cd = res.headers.get('content-disposition') - - expect(cd).toMatch(/opensensemap_org-download-Temperatur/) - }) - - it('GET /boxes/data should NOT include content-disposition when download=false', async () => { - const req = new Request( - `${BASE_URL}/boxes/data/?boxid=${deviceId},${deviceId}&phenomenon=Temperatur&download=false`, - { headers: { Authorization: `Bearer ${jwt}` } }, - ) - - const res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - - const cd = res.headers.get('content-disposition') - - expect(cd).toBeNull() - }) - - // --------------------------- - // Bounding box validation - // --------------------------- - it('GET /boxes/data invalid bbox (too many values)', async () => { - const req = new Request( - `${BASE_URL}/boxes/data/?boxid=${deviceId}&phenomenon=Temperatur&bbox=1,2,3,4,5`, - { headers: { Authorization: `Bearer ${jwt}` } }, - ) - - let res: Response - try { - res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - } catch (response) { - res = response as Response - } - - expect(res.status).toBe(422) - - const json = await res.json() - expect(json.code).toBe('Unprocessable Content') - }) - - it('should allow to specify bounding boxes with area greater than a single hemisphere', async () => { - const req = new Request( - `${BASE_URL}/boxes/data/?phenomenon=Temperatur&bbox=-180,-90,180,90`, - ) - - const res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - - expect(res.status).toBe(200) - - expect(res.headers.get('content-type')).toContain('text/csv') - - const bodyText = await res.text() - - const lines = bodyText.split('\n') - expect(lines.length).toBeGreaterThan(1) - }) - - it('GET /boxes/data invalid bbox (too few values)', async () => { - const req = new Request( - `${BASE_URL}/boxes/data/?boxid=${deviceId}&phenomenon=Temperatur&bbox=1,2,3`, - { headers: { Authorization: `Bearer ${jwt}` } }, - ) - - let res: Response - try { - res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - } catch (response) { - res = response as Response - } - - expect(res.status).toBe(422) - }) - - it('GET /boxes/data invalid bbox (not floats)', async () => { - const req = new Request( - `${BASE_URL}/boxes/data/?boxid=${deviceId}&phenomenon=Temperatur&bbox=1,2,east,4`, - { headers: { Authorization: `Bearer ${jwt}` } }, - ) - - let res: Response - try { - res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - } catch (response) { - res = response as Response - } - - expect(res.status).toBe(422) - }) - - it('GET /boxes/data JSON by grouptag', async () => { - const GROUPTAG = 'bytag' - - // Add tag to device - await drizzleClient - .update(device) - .set({ tags: [GROUPTAG] }) - .where(eq(device.id, deviceId)) - - const url = `${BASE_URL}/api/boxes/data?grouptag=${GROUPTAG}&format=json&columns=sensorId,value&phenomenon=Temperatur` - const req = new Request(url, { - headers: { Authorization: `Bearer ${jwt}` }, - }) - - const res = await boxesDataLoader({ - request: req, - params: {}, - context: {} as AppLoadContext, - }) - - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('application/json') - - const body = await res.json() - expect(Array.isArray(body)).toBe(true) - expect(body).toHaveLength(expectedMeasurementsCount) - }) - - afterAll(async () => { - await deleteDevice({ id: deviceId }) - await deleteDevice({ id: outdoorDeviceId }) - await deleteUserByEmail(BOXES_DATA_TEST_USER.email) - }) -}) diff --git a/tests/routes/api.boxes.spec.ts b/tests/routes/api.boxes.spec.ts index f8e36e38..909b5281 100644 --- a/tests/routes/api.boxes.spec.ts +++ b/tests/routes/api.boxes.spec.ts @@ -46,7 +46,7 @@ describe('openSenseMap API Routes: /boxes', () => { name: 'Test Weather Station', location: [7.596, 51.969], exposure: 'outdoor', - //model: 'homeV2Wifi', + model: 'homeV2Wifi', grouptag: ['weather', 'test'], sensors: [ { diff --git a/tests/routes/api.device.feinstaub.spec.ts b/tests/routes/api.device.feinstaub.spec.ts deleted file mode 100644 index 4fdde0f7..00000000 --- a/tests/routes/api.device.feinstaub.spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { BASE_URL } from 'vitest.setup' -import { createToken } from '~/lib/jwt' -import { registerUser } from '~/lib/user-service.server' -import { createDevice, deleteDevice, getDevice } from '~/models/device.server' -import { deleteUserByEmail } from '~/models/user.server' -import { action as deviceUpdateAction } from '~/routes/api.device.$deviceId' -import { type User, type Device } from '~/schema' - -const TEST_USER = { - name: 'feinstaubAddonUpdateTestUser', - email: 'feinstaubUpdate.addon@test', - password: 'secureTestPassword123!', -} - -let user: User -let jwt: string -let baseDevice: Device - -const generateMinimalDevice = (model = 'homeEthernetFeinstaub') => ({ - exposure: 'mobile', - latitude: 12.34, - longitude: 56.78, - name: 'senseBox' + Date.now(), - model: model, -}) - -describe('Device API: Feinstaub Addon behavior', () => { - let queryableDevice: Device | null = null - - beforeAll(async () => { - const testUser = await registerUser( - TEST_USER.name, - TEST_USER.email, - TEST_USER.password, - 'en_US', - ) - user = testUser as User - const { token: t } = await createToken(testUser as User) - jwt = t - - queryableDevice = await createDevice( - { - ...generateMinimalDevice(), - latitude: 123, - longitude: 12, - tags: ['newgroup'], - }, - (testUser as User).id, - ) - }) - - afterAll(async () => { - await deleteUserByEmail(TEST_USER.email) - await deleteDevice({ id: queryableDevice!.id }) - }) - - it('should allow to register a homeEthernetFeinstaub device and include SDS011 sensors', async () => { - const device = await createDevice(generateMinimalDevice(), user.id) - - const fetched = await getDevice({ id: device.id }) - expect(fetched).toBeDefined() - - const hasPM10 = fetched!.sensors.some( - (s) => s.sensorType === 'SDS 011' && s.title === 'PM10', - ) - const hasPM25 = fetched!.sensors.some( - (s) => s.sensorType === 'SDS 011' && s.title === 'PM2.5', - ) - - expect(hasPM10).toBe(true) - expect(hasPM25).toBe(true) - - await deleteDevice({ id: device.id }) - }) - - it('should allow to register a homeWifiFeinstaub device and include SDS011 sensors', async () => { - const device = await createDevice( - generateMinimalDevice('homeWifiFeinstaub'), - user.id, - ) - - const fetched = await getDevice({ id: device.id }) - expect(fetched).toBeDefined() - - const hasPM10 = fetched!.sensors.some( - (s) => s.sensorType === 'SDS 011' && s.title === 'PM10', - ) - const hasPM25 = fetched!.sensors.some( - (s) => s.sensorType === 'SDS 011' && s.title === 'PM2.5', - ) - - expect(hasPM10).toBe(true) - expect(hasPM25).toBe(true) - - await deleteDevice({ id: device.id }) - }) - - it('should allow to add the feinstaub addon via PUT for a homeWifi device', async () => { - const device = await createDevice( - generateMinimalDevice('homeWifiFeinstaub'), - user.id, - ) - - const updatePayload = { addons: { add: 'feinstaub' } } - - const request = new Request(`${BASE_URL}/${device.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(updatePayload), - }) as unknown as Request - - const response: any = await deviceUpdateAction({ - request, - params: { deviceId: device.id }, - context: {} as any, - }) - - expect(response.status).toBe(200) - const data = await response.json() - - expect(data.model).toBe('homeWifiFeinstaub') - - const hasPM10 = data.sensors.some( - (s: any) => s.sensorType === 'SDS 011' && s.title === 'PM10', - ) - const hasPM25 = data.sensors.some( - (s: any) => s.sensorType === 'SDS 011' && s.title === 'PM2.5', - ) - - expect(hasPM10).toBe(true) - expect(hasPM25).toBe(true) - - const secondRequest = new Request(`${BASE_URL}/${device.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(updatePayload), - }) as unknown as Request - - // Second PUT should be idempotent — same sensors - const secondResponse: any = await deviceUpdateAction({ - request: secondRequest, - params: { deviceId: device.id }, - context: {} as any, - }) - - expect(secondResponse.status).toBe(200) - const secondData = await secondResponse.json() - expect(secondData.sensors).toEqual(data.sensors) - - await deleteDevice({ id: device.id }) - }) - - it('should do nothing when adding the feinstaub addon to a non-home device', async () => { - const device = await createDevice( - { - ...generateMinimalDevice('Custom'), - // sensors: [{ title: 'temp', unit: 'K', sensorType: 'some Sensor' }], - }, - user.id, - ) - - const updatePayload = { addons: { add: 'feinstaub' } } - - const request = new Request(`${BASE_URL}/${device.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(updatePayload), - }) as unknown as Request - - const response: any = await deviceUpdateAction({ - request, - params: { deviceId: device.id }, - context: {} as any, - }) - - expect(response.status).toBe(200) - const data = await response.json() - - // Model should not change - expect(data.model).toBe('Custom') - - // Should not have SDS011 sensors - const hasPM10 = data.sensors.some( - (s: any) => s.sensorType === 'SDS 011' && s.title === 'PM10', - ) - const hasPM25 = data.sensors.some( - (s: any) => s.sensorType === 'SDS 011' && s.title === 'PM2.5', - ) - expect(hasPM10).toBe(false) - expect(hasPM25).toBe(false) - - await deleteDevice({ id: device.id }) - }) -}) diff --git a/tests/routes/api.device.sensors.spec.ts b/tests/routes/api.device.sensors.spec.ts deleted file mode 100644 index 93772720..00000000 --- a/tests/routes/api.device.sensors.spec.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { BASE_URL } from 'vitest.setup' -import { createToken } from '~/lib/jwt' -import { registerUser } from '~/lib/user-service.server' -import { createDevice, deleteDevice, getDevice } from '~/models/device.server' -import { deleteUserByEmail } from '~/models/user.server' -import { action as deviceUpdateAction } from '~/routes/api.device.$deviceId' -import { type User, type Device } from '~/schema' - -const DEVICE_TEST_USER = { - name: 'deviceTestUpdateSensors', - email: 'test@deviceSensorsUpdate.endpoint', - password: 'highlySecurePasswordForTesting', -} - -let user: User -let jwt: string -let queryableDevice: Device - -const generateMinimalDevice = () => ({ - exposure: 'mobile', - location: { lat: 12.34, lng: 56.78 }, - name: 'senseBox' + Date.now(), -}) - -describe('Device Sensors API: updating sensors', () => { - beforeAll(async () => { - const testUser = await registerUser( - DEVICE_TEST_USER.name, - DEVICE_TEST_USER.email, - DEVICE_TEST_USER.password, - 'en_US', - ) - user = testUser as User - const { token } = await createToken(user) - jwt = token - - queryableDevice = await createDevice( - { - ...generateMinimalDevice(), - latitude: 12.34, - longitude: 56.78, - sensors: [ - { - title: 'Temperature', - unit: '°C', - sensorType: 'DHT22', - }, - { - title: 'Humidity', - unit: '%', - sensorType: 'DHT22', - }, - { - title: 'Pressure', - unit: 'hPa', - sensorType: 'BMP280', - }, - { - title: 'Light', - unit: 'lux', - sensorType: 'TSL2561', - }, - { - title: 'UV', - unit: 'µW/cm²', - sensorType: 'VEML6070', - }, - ], - }, - user.id, - ) - }) - - afterAll(async () => { - await deleteDevice({ id: queryableDevice.id }) - await deleteUserByEmail(DEVICE_TEST_USER.email) - }) - - it('should allow to add a sensor', async () => { - const newSensor = { - title: 'PM10', - unit: 'µg/m³', - sensorType: 'SDS 011', - icon: 'osem-particulate-matter', - new: 'true', - edited: 'true', - } - - const payload = { sensors: [newSensor] } - - const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(payload), - }) as unknown as Request - - const response: any = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice.id }, - context: {} as any, - }) - - expect(response.status).toBe(200) - const data = await response.json() - - const addedSensor = data.sensors.find( - (s: any) => s.title === newSensor.title, - ) - expect(addedSensor).toBeDefined() - expect(addedSensor.unit).toBe(newSensor.unit) - expect(addedSensor.sensorType).toBe(newSensor.sensorType) - expect(addedSensor.icon).toBe(newSensor.icon) - - const freshDevice = await getDevice({ id: queryableDevice.id }) - const verifiedSensor = freshDevice?.sensors?.find( - (s: any) => s.title === newSensor.title, - ) - expect(verifiedSensor).toBeDefined() - }) - - it('should allow to add multiple sensors via PUT', async () => { - const newSensors = [ - { - title: 'PM2.5', - unit: 'µg/m³', - sensorType: 'SDS 011', - edited: 'true', - new: 'true', - }, - { - title: 'CO2', - unit: 'ppm', - sensorType: 'MH-Z19', - edited: 'true', - new: 'true', - }, - ] - - const payload = { sensors: newSensors } - - const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(payload), - }) as unknown as Request - - const response: any = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice.id }, - context: {} as any, - }) - - expect(response.status).toBe(200) - const data = await response.json() - - const hasPM25 = data.sensors.some((s: any) => s.title === 'PM2.5') - const hasCO2 = data.sensors.some((s: any) => s.title === 'CO2') - - expect(hasPM25).toBe(true) - expect(hasCO2).toBe(true) - - const freshDevice = await getDevice({ id: queryableDevice.id }) - const verifiedPM25 = freshDevice?.sensors?.some( - (s: any) => s.title === 'PM2.5', - ) - const verifiedCO2 = freshDevice?.sensors?.some( - (s: any) => s.title === 'CO2', - ) - - expect(verifiedPM25).toBe(true) - expect(verifiedCO2).toBe(true) - }) - - it('should allow to edit a sensor', async () => { - const freshDevice = await getDevice({ id: queryableDevice.id }) - const existingSensor = freshDevice?.sensors?.[0] - - if (!existingSensor) { - throw new Error('No sensors found on device') - } - - const updatedSensor = { - _id: existingSensor.id, - title: 'editedTitle', - unit: 'editedUnit', - sensorType: 'editedType', - icon: 'editedIcon', - edited: 'true', - } - - const payload = { sensors: [updatedSensor] } - - const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(payload), - }) as unknown as Request - - const response: any = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice.id }, - context: {} as any, - }) - - expect(response.status).toBe(200) - const data = await response.json() - - const editedSensor = data.sensors.find( - (s: any) => s._id === existingSensor.id, - ) - expect(editedSensor).toBeDefined() - expect(editedSensor.title).toBe(updatedSensor.title) - expect(editedSensor.unit).toBe(updatedSensor.unit) - expect(editedSensor.sensorType).toBe(updatedSensor.sensorType) - }) - - it('should allow to delete a single sensor via PUT', async () => { - const freshDevice = await getDevice({ id: queryableDevice.id }) - - if (!freshDevice?.sensors || freshDevice.sensors.length < 2) { - throw new Error('Not enough sensors to test deletion') - } - - const sensorToDelete = freshDevice.sensors[0] - const initialSensorCount = freshDevice.sensors.length - - const payload = { - sensors: [ - { - _id: sensorToDelete.id, - deleted: true, - }, - ], - } - - const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(payload), - }) as unknown as Request - - const response: any = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice.id }, - context: {} as any, - }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.sensors).toHaveLength(initialSensorCount - 1) - - const deletedSensorStillExists = data.sensors.some( - (s: any) => s._id === sensorToDelete.id, - ) - expect(deletedSensorStillExists).toBe(false) - - const updatedDevice = await getDevice({ id: queryableDevice.id }) - expect(updatedDevice?.sensors?.length).toBe(initialSensorCount - 1) - }) - - it('should allow to delete multiple sensors at once', async () => { - const freshDevice = await getDevice({ id: queryableDevice.id }) - - if (!freshDevice?.sensors || freshDevice.sensors.length < 3) { - throw new Error('Not enough sensors to test deletion') - } - - const sensorsToDelete = freshDevice.sensors.slice(0, 2).map((s: any) => ({ - _id: s.id, - deleted: true, - })) - - const initialSensorCount = freshDevice.sensors.length - - const payload = { sensors: sensorsToDelete } - - const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(payload), - }) as unknown as Request - - const response: any = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice.id }, - context: {} as any, - }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.sensors).toHaveLength(initialSensorCount - 2) - - const remainingSensors = data.sensors.map((s: any) => s._id) - - sensorsToDelete.forEach((s: any) => { - expect(remainingSensors).not.toContain(s._id) - }) - - const updatedDevice = await getDevice({ id: queryableDevice.id }) - expect(updatedDevice?.sensors?.length).toBe(initialSensorCount - 2) - }) - - it('should NOT allow to delete all sensors', async () => { - const freshDevice = await getDevice({ id: queryableDevice.id }) - - if (!freshDevice?.sensors) { - throw new Error('No sensors found on device') - } - - const allSensors = freshDevice.sensors.map((s: any) => ({ - _id: s.id, - deleted: true, - })) - - const payload = { sensors: allSensors } - - const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(payload), - }) as unknown as Request - - const response: any = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice.id }, - context: {} as any, - }) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.code).toBe('BadRequest') - expect(data.message).toContain('Unable to delete sensor') - - const unchangedDevice = await getDevice({ id: queryableDevice.id }) - expect(unchangedDevice?.sensors?.length).toBe(freshDevice.sensors.length) - }) -}) diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index 8e8dc274..21f2f981 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -9,10 +9,7 @@ import { createToken } from '~/lib/jwt' import { registerUser } from '~/lib/user-service.server' import { createDevice, deleteDevice } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' -import { - loader as deviceLoader, - action as deviceUpdateAction, -} from '~/routes/api.device.$deviceId' +import { loader as deviceLoader } from '~/routes/api.device.$deviceId' import { loader as devicesLoader, action as devicesAction, @@ -53,13 +50,33 @@ describe('openSenseMap API Routes: /boxes', () => { ...generateMinimalDevice(), latitude: 123, longitude: 12, - tags: ['testgroup'], + tags: ['newgroup'], }, (testUser as User).id, ) }) describe('GET', () => { + it('should search for boxes with a specific name', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&name=${queryableDevice?.name}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + + // Act + const response: any = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + expect(response).toBeDefined() + expect(Array.isArray(response?.features)).toBe(true) + expect(response?.features.length).lessThanOrEqual(5) // 5 is default limit + }) + it('should search for boxes with a specific name and limit the results', async () => { // Arrange const request = new Request( @@ -283,7 +300,7 @@ describe('openSenseMap API Routes: /boxes', () => { it('should allow to filter boxes by grouptag', async () => { // Arrange - const request = new Request(`${BASE_URL}?grouptag=testgroup`, { + const request = new Request(`${BASE_URL}?grouptag=newgroup`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }) @@ -347,72 +364,6 @@ describe('openSenseMap API Routes: /boxes', () => { } } }) - - it('should reject filtering boxes near a location with wrong parameter values', async () => { - // Arrange - const request = new Request(`${BASE_URL}?near=test,60`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) - - // Act & Assert - await expect(async () => { - await devicesLoader({ - request: request, - } as LoaderFunctionArgs) - }).rejects.toThrow() - }) - - it('should return 422 error on wrong format parameter', async () => { - // Arrange - const request = new Request(`${BASE_URL}?format=potato`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) - - try { - await devicesLoader({ - request: request, - } as LoaderFunctionArgs) - expect(true).toBe(false) - } catch (error) { - expect(error).toBeInstanceOf(Response) - expect((error as Response).status).toBe(422) - - const errorData = await (error as Response).json() - expect(errorData.error).toBe('Invalid format parameter') - } - }) - - it('should return geojson format when requested', async () => { - // Arrange - const request = new Request(`${BASE_URL}?format=geojson`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) - - // Act - const geojsonData: any = await devicesLoader({ - request: request, - } as LoaderFunctionArgs) - - expect(geojsonData).toBeDefined() - if (geojsonData) { - // Assert - this should always be GeoJSON since that's what the loader returns - expect(geojsonData.type).toBe('FeatureCollection') - expect(Array.isArray(geojsonData.features)).toBe(true) - - if (geojsonData.features.length > 0) { - expect(geojsonData.features[0].type).toBe('Feature') - expect(geojsonData.features[0].geometry).toBeDefined() - // @ts-ignore - expect(geojsonData.features[0].geometry.coordinates[0]).toBeDefined() - // @ts-ignore - expect(geojsonData.features[0].geometry.coordinates[1]).toBeDefined() - expect(geojsonData.features[0].properties).toBeDefined() - } - } - }) }) describe('POST', () => { @@ -616,252 +567,6 @@ describe('openSenseMap API Routes: /boxes', () => { }) }) - describe('PUT', () => { - it('should allow to update the device via PUT', async () => { - const update_payload = { - name: 'neuername', - exposure: 'indoor', - grouptag: 'testgroup', - description: 'total neue beschreibung', - location: { lat: 54.2, lng: 21.1 }, - weblink: 'http://www.google.de', - image: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - - const data = await response.json() - - expect(data.name).toBe(update_payload.name) - expect(data.exposure).toBe(update_payload.exposure) - expect(Array.isArray(data.grouptag)).toBe(true) - expect(data.grouptag).toContain(update_payload.grouptag) - expect(data.description).toBe(update_payload.description) - - expect(data.currentLocation).toEqual({ - type: 'Point', - coordinates: [ - update_payload.location.lng, - update_payload.location.lat, - ], - timestamp: expect.any(String), - }) - - expect(data.loc).toEqual([ - { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [ - update_payload.location.lng, - update_payload.location.lat, - ], - timestamp: expect.any(String), - }, - }, - ]) - }) - - it('should allow to update the device via PUT with array as grouptags', async () => { - const update_payload = { - name: 'neuername', - exposure: 'outdoor', - grouptag: ['testgroup'], - description: 'total neue beschreibung', - location: { lat: 54.2, lng: 21.1 }, - weblink: 'http://www.google.de', - image: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response: any = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - - const data = await response.json() - expect(data.name).toBe(update_payload.name) - expect(data.exposure).toBe(update_payload.exposure) - - expect(Array.isArray(data.grouptag)).toBe(true) - expect(data.grouptag).toEqual(update_payload.grouptag) - - expect(data.description).toBe(update_payload.description) - expect(data.currentLocation.coordinates).toEqual([ - update_payload.location.lng, - update_payload.location.lat, - ]) - expect(data.loc[0].geometry.coordinates).toEqual([ - update_payload.location.lng, - update_payload.location.lat, - ]) - - //TODO: this fails, check if we actually need timestamps in images - // const parts = data.image.split('_') - // const ts36 = parts[1].replace('.png', '') - // const tsMs = parseInt(ts36, 36) * 1000 - // expect(Date.now() - tsMs).toBeLessThan(1000) - }) - it('should remove image when deleteImage=true', async () => { - const update_payload = { - deleteImage: true, - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.image).toBeNull() - }) - - it('should nullify description when set to empty string', async () => { - const update_payload = { - description: '', - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.description).toBeNull() - }) - - it('should clear group tags when empty array provided', async () => { - const update_payload = { - grouptag: [], - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.grouptag).toHaveLength(0) - }) - - it('should merge addons.add into grouptags', async () => { - const update_payload = { - addons: { add: 'feinstaub' }, - grouptag: ['existinggroup'], - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - - expect(Array.isArray(data.grouptag)).toBe(true) - expect(data.grouptag).toContain('existinggroup') - expect(data.grouptag).toContain('feinstaub') - }) - - it('should accept multi-valued grouptag array', async () => { - const update_payload = { - grouptag: ['tag1', 'tag2', 'tag3'], - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.grouptag).toEqual( - expect.arrayContaining(['tag1', 'tag2', 'tag3']), - ) - }) - }) - describe('DELETE', () => { let deletableDevice: Device | null = null diff --git a/tests/routes/api.location.spec.ts b/tests/routes/api.location.spec.ts index ec572df8..abb48c3b 100644 --- a/tests/routes/api.location.spec.ts +++ b/tests/routes/api.location.spec.ts @@ -22,6 +22,7 @@ const TEST_BOX = { tags: [], latitude: 0, longitude: 0, + model: 'luftdaten.info', mqttEnabled: false, ttnEnabled: false, sensors: [ diff --git a/tests/routes/api.measurements.spec.ts b/tests/routes/api.measurements.spec.ts index 7414ba8c..51a7af64 100644 --- a/tests/routes/api.measurements.spec.ts +++ b/tests/routes/api.measurements.spec.ts @@ -22,7 +22,7 @@ const TEST_BOX = { tags: [], latitude: 0, longitude: 0, - //model: 'luftdaten.info', + model: 'luftdaten.info', mqttEnabled: false, ttnEnabled: false, sensors: [ diff --git a/tests/routes/api.tags.spec.ts b/tests/routes/api.tags.spec.ts index c7c0ab93..6617bcb9 100644 --- a/tests/routes/api.tags.spec.ts +++ b/tests/routes/api.tags.spec.ts @@ -12,7 +12,7 @@ const TEST_TAG_BOX = { name: `'${TAGS_TEST_USER.name}'s Box`, exposure: 'outdoor', expiresAt: null, - tags: ['tag1', 'tag2', 'testgroup'], + tags: ['tag1', 'tag2'], latitude: 0, longitude: 0, model: 'luftdaten.info', @@ -79,7 +79,7 @@ describe('openSenseMap API Routes: /tags', () => { 'application/json; charset=utf-8', ) expect(Array.isArray(body.data)).toBe(true) - expect(body.data).toHaveLength(3) + expect(body.data).toHaveLength(2) }) afterAll(async () => { diff --git a/tests/utils/measurement-server-helper.spec.ts b/tests/utils/measurement-server-helper.spec.ts index de81788f..8cdf8b02 100644 --- a/tests/utils/measurement-server-helper.spec.ts +++ b/tests/utils/measurement-server-helper.spec.ts @@ -34,7 +34,7 @@ const DEVICE_SENSOR_ID_BOX = { tags: [], latitude: 0, longitude: 0, - //model: 'luftdaten.info', + model: 'luftdaten.info', mqttEnabled: false, ttnEnabled: false, sensors: [ From f54434ac761434c282dcd03d022b750ded2f7e5c Mon Sep 17 00:00:00 2001 From: JerryVincent Date: Wed, 14 Jan 2026 13:39:14 +0100 Subject: [PATCH 3/4] This commit merges dev into the branch. --- app/components/mydevices/dt/columns.tsx | 92 ++++++++++++------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/app/components/mydevices/dt/columns.tsx b/app/components/mydevices/dt/columns.tsx index 13be3171..f74bc900 100644 --- a/app/components/mydevices/dt/columns.tsx +++ b/app/components/mydevices/dt/columns.tsx @@ -1,5 +1,4 @@ 'use client' -'use client' import { type ColumnDef } from '@tanstack/react-table' import { ArrowUpDown, ClipboardCopy, Ellipsis } from 'lucide-react' @@ -104,50 +103,51 @@ export function getColumns( cell: ({ row }) => { const senseBox = row.original - return ( - - - - - - Actions - - - Overview - - - Show on map - - - Edit - - - Data upload - - - - Support - - - navigator.clipboard.writeText(senseBox?.id)} - className="cursor-pointer" + return ( + + + + + - Copy ID - - - - ) + Actions + + + Overview + + + Show on map + + + Edit + + + Data upload + + + + Support + + + navigator.clipboard.writeText(senseBox?.id)} + className="cursor-pointer" + > + Copy ID + + + + ) + }, }, - }, -] + ] +} From 25285ef21bdc178f1e7d2bd59ce397a31fcc33ab Mon Sep 17 00:00:00 2001 From: JerryVincent Date: Wed, 14 Jan 2026 13:59:25 +0100 Subject: [PATCH 4/4] this commit merges origin/dev --- app/components/mydevices/dt/columns.tsx | 1 + app/routes/profile.$username.tsx | 29 ++++++++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/app/components/mydevices/dt/columns.tsx b/app/components/mydevices/dt/columns.tsx index ee201341..f5b2ad90 100644 --- a/app/components/mydevices/dt/columns.tsx +++ b/app/components/mydevices/dt/columns.tsx @@ -150,3 +150,4 @@ export function getColumns( }, }, ] +} \ No newline at end of file diff --git a/app/routes/profile.$username.tsx b/app/routes/profile.$username.tsx index 02d243f4..46bdb2a1 100644 --- a/app/routes/profile.$username.tsx +++ b/app/routes/profile.$username.tsx @@ -1,6 +1,7 @@ +import { useTranslation } from 'react-i18next' import { type LoaderFunctionArgs, redirect, useLoaderData } from 'react-router' import ErrorMessage from '~/components/error-message' -import { columns } from '~/components/mydevices/dt/columns' +import { getColumns } from '~/components/mydevices/dt/columns' import { DataTable } from '~/components/mydevices/dt/data-table' import { NavBar } from '~/components/nav-bar' import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar' @@ -79,6 +80,9 @@ export default function () { const { profile, sensorsCount, measurementsCount } = useLoaderData() + const { t } = useTranslation('profile') + const columnsTranslation = useTranslation('data-table') + // const sortedBadges = sortBadges(allBadges, userBackpack); return ( @@ -103,8 +107,10 @@ export default function () { {profile?.user?.name || ''}

- User since{' '} - {new Date(profile?.user?.createdAt || '').toLocaleDateString()} + {t('user_since')}{' '} + {new Date(profile?.user?.createdAt || '').toLocaleDateString( + t('locale'), + )}

@@ -114,7 +120,7 @@ export default function () { {profile?.user?.devices.length} - Devices + {t('devices')}
@@ -122,7 +128,7 @@ export default function () { {sensorsCount} - Sensors + {t('sensors')}
@@ -130,7 +136,7 @@ export default function () { {measurementsCount} - Measurements + {t('measurements')}
{/*
@@ -138,7 +144,7 @@ export default function () { {userBackpack.length} - Badges + {t("badges")}
*/}
@@ -197,9 +203,12 @@ export default function () { {profile?.user?.devices && ( <>
- Devices + {t('devices')}
- + )}
@@ -215,4 +224,4 @@ export function ErrorBoundary() { ) -} +} \ No newline at end of file