diff --git a/app/components/device/new/custom-device-config.tsx b/app/components/device/new/custom-device-config.tsx index e7027ba7..ac950940 100644 --- a/app/components/device/new/custom-device-config.tsx +++ b/app/components/device/new/custom-device-config.tsx @@ -1,13 +1,37 @@ -import { X } from 'lucide-react' +import { FileJson, Library, Lock, Search, X } from 'lucide-react' import { useState, useEffect } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { type Sensor } from './sensors-info' +import { type CustomDeviceSchemaUpload, type Sensor } from './sensors-info' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Separator } from '~/components/ui/separator' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs' +import { uploadedDeviceSchemaV1 } from '~/lib/device-schemas/device-schema-v1' + +type RegistryDeviceSchema = { + id: string + slug: string + name: string + description: string | null + tags: string[] + visibility: 'private' | 'public' + versionId: string + version: string + formatVersion: string + hash: string + publishedAt: string | null + isOwner: boolean + content: NonNullable +} + +type RegistryResponse = { + schemas: RegistryDeviceSchema[] +} export function CustomDeviceConfig() { const { setValue, watch } = useFormContext() @@ -16,6 +40,21 @@ export function CustomDeviceConfig() { const [sensors, setSensors] = useState( () => watch('selectedSensors') || [], ) + const [deviceSchema, setDeviceSchema] = useState( + () => watch('deviceSchema'), + ) + const [deviceSchemaVersionId, setDeviceSchemaVersionId] = useState< + string | undefined + >(() => watch('deviceSchemaVersionId')) + const [selectedRegistrySchema, setSelectedRegistrySchema] = useState< + RegistryDeviceSchema | undefined + >(() => watch('deviceSchemaRegistrySelection')) + const [schemaError, setSchemaError] = useState(null) + const [registryQuery, setRegistryQuery] = useState('') + const [registrySchemas, setRegistrySchemas] = useState< + RegistryDeviceSchema[] + >([]) + const [isRegistryLoading, setIsRegistryLoading] = useState(false) const [newSensor, setNewSensor] = useState({ title: '', unit: '', @@ -29,14 +68,71 @@ export function CustomDeviceConfig() { if (savedSensors.length > 0) { setSensors(savedSensors) } + + const savedDeviceSchema = watch('deviceSchema') + if (savedDeviceSchema) { + setDeviceSchema(savedDeviceSchema) + } + + const savedDeviceSchemaVersionId = watch('deviceSchemaVersionId') + if (savedDeviceSchemaVersionId) { + setDeviceSchemaVersionId(savedDeviceSchemaVersionId) + } + + const savedRegistrySchema = watch('deviceSchemaRegistrySelection') + if (savedRegistrySchema) { + setSelectedRegistrySchema(savedRegistrySchema) + } }, [watch]) + useEffect(() => { + const abortController = new AbortController() + const timeout = setTimeout(async () => { + setIsRegistryLoading(true) + + try { + const params = new URLSearchParams() + if (registryQuery.trim()) params.set('q', registryQuery.trim()) + + const response = await fetch( + `/resources/device-schemas?${params.toString()}`, + { signal: abortController.signal }, + ) + + if (!response.ok) throw new Error(t('device_schema_registry_error')) + + const data = (await response.json()) as RegistryResponse + setRegistrySchemas(data.schemas) + } catch (error) { + if (abortController.signal.aborted) return + setSchemaError( + error instanceof Error + ? error.message + : t('device_schema_registry_error'), + ) + } finally { + if (!abortController.signal.aborted) setIsRegistryLoading(false) + } + }, 250) + + return () => { + abortController.abort() + clearTimeout(timeout) + } + }, [registryQuery, t]) + const updateNewSensor = (field: keyof Sensor, value: string) => { setNewSensor((prev) => ({ ...prev, [field]: value })) } const addSensor = () => { - if (newSensor.title && newSensor.unit && newSensor.sensorType) { + if ( + !deviceSchema && + !deviceSchemaVersionId && + newSensor.title && + newSensor.unit && + newSensor.sensorType + ) { const updatedSensors = [...sensors, newSensor] setSensors(updatedSensors) setValue('selectedSensors', updatedSensors) // Sync with form @@ -45,74 +141,321 @@ export function CustomDeviceConfig() { } const removeSensor = (index: number) => { + if (deviceSchema || deviceSchemaVersionId) return + const updatedSensors = sensors.filter((_, i) => i !== index) setSensors(updatedSensors) setValue('selectedSensors', updatedSensors) // Sync with form } + const importDeviceSchema = async (file: File) => { + setSchemaError(null) + + try { + const parsedJson = JSON.parse(await file.text()) + const parsedSchema = uploadedDeviceSchemaV1.parse(parsedJson) + const schemaSensors = parsedSchema.sensors.map((sensor) => ({ + id: sensor.id, + title: sensor.title, + unit: sensor.unit, + sensorType: sensor.sensorType, + icon: sensor.icon, + sensorWikiType: sensor.sensorWikiType, + sensorWikiPhenomenon: sensor.sensorWikiPhenomenon, + sensorWikiUnit: sensor.sensorWikiUnit, + })) + + setDeviceSchema(parsedSchema) + setDeviceSchemaVersionId(undefined) + setSelectedRegistrySchema(undefined) + setSensors(schemaSensors) + setValue('deviceSchema', parsedSchema) + setValue('deviceSchemaVersionId', undefined) + setValue('deviceSchemaRegistrySelection', undefined) + setValue('selectedSensors', schemaSensors) + } catch (error) { + setSchemaError( + error instanceof Error + ? error.message + : t('device_schema_invalid_file'), + ) + } + } + + const clearDeviceSchema = () => { + setDeviceSchema(undefined) + setDeviceSchemaVersionId(undefined) + setSelectedRegistrySchema(undefined) + setSchemaError(null) + setSensors([]) + setValue('deviceSchema', undefined) + setValue('deviceSchemaVersionId', undefined) + setValue('deviceSchemaRegistrySelection', undefined) + setValue('selectedSensors', []) + } + + const useRegistrySchema = (schema: RegistryDeviceSchema) => { + const schemaSensors = schema.content.sensors.map((sensor) => ({ + id: sensor.id, + title: sensor.title, + unit: sensor.unit, + sensorType: sensor.sensorType, + icon: sensor.icon, + sensorWikiType: sensor.sensorWikiType, + sensorWikiPhenomenon: sensor.sensorWikiPhenomenon, + sensorWikiUnit: sensor.sensorWikiUnit, + })) + + setSchemaError(null) + setDeviceSchema(undefined) + setDeviceSchemaVersionId(schema.versionId) + setSelectedRegistrySchema(schema) + setSensors(schemaSensors) + setValue('deviceSchema', undefined) + setValue('deviceSchemaVersionId', schema.versionId) + setValue('deviceSchemaRegistrySelection', schema) + setValue('selectedSensors', schemaSensors) + } + + const hasLockedSchema = !!deviceSchema || !!deviceSchemaVersionId + const selectedSchemaName = deviceSchema?.name ?? selectedRegistrySchema?.name + const selectedSchemaVersion = + deviceSchema?.version ?? selectedRegistrySchema?.version + const selectedSchemaSensorCount = + deviceSchema?.sensors.length ?? + selectedRegistrySchema?.content.sensors.length + return (
-
-
-
- - updateNewSensor('title', e.target.value)} - placeholder="e.g., Temperature" - /> + + + {t('device_schema_tab')} + {t('manual_sensors_tab')} + + {t('selected_sensors_tab', { count: sensors.length })} + + + + +
+
+
+
+ + {t('device_schema_registry')} +
+

+ {t('device_schema_registry_text')} +

+
+
+ + setRegistryQuery(event.target.value)} + placeholder={t('device_schema_registry_search')} + className="pl-9" + /> +
+
+ {isRegistryLoading && ( +

+ {t('loading')} +

+ )} + {!isRegistryLoading && registrySchemas.length === 0 && ( +

+ {t('device_schema_registry_empty')} +

+ )} + {registrySchemas.map((schema) => ( + + +
+
+ {schema.name} + v{schema.version} + + {schema.content.sensors.length} {t('sensors')} + +
+ {schema.description && ( +

+ {schema.description} +

+ )} +
+ +
+
+ ))} +
+
-
- - updateNewSensor('unit', e.target.value)} - placeholder="e.g., °C" - /> + +
+
+
+
+ + {t('device_schema_upload')} +
+

+ {t('device_schema_upload_text')} +

+
+
+ { + const file = event.target.files?.[0] + if (file) void importDeviceSchema(file) + event.target.value = '' + }} + /> + {hasLockedSchema && ( + + )} +
+
+ + {hasLockedSchema && ( + + + + {selectedSchemaName ?? t('device_schema_registry_selection')} + {selectedSchemaVersion && ( + v{selectedSchemaVersion} + )} + + + {t('device_schema_locked_sensors', { + count: selectedSchemaSensorCount ?? sensors.length, + })} + + + )} + + {schemaError && ( +

{schemaError}

+ )}
+ + + + {hasLockedSchema && ( + + + {t('manual_sensors_locked')} + + {t('manual_sensors_locked_text')} + + + )} +
- - updateNewSensor('sensorType', e.target.value)} - placeholder="e.g., HDC1080" - /> -
-
- -
- - {sensors.length > 0 && } - {sensors.map((sensor, index) => ( - - -
- {sensor.title} ({sensor.unit} - ) - {sensor.sensorType} +
+
+ + updateNewSensor('title', e.target.value)} + placeholder="e.g., Temperature" + disabled={hasLockedSchema} + /> +
+
+ + updateNewSensor('unit', e.target.value)} + placeholder="e.g., °C" + disabled={hasLockedSchema} + /> +
+
+ + + updateNewSensor('sensorType', e.target.value) + } + placeholder="e.g., HDC1080" + disabled={hasLockedSchema} + /> +
- - - ))} +
+ + + + {sensors.length === 0 ? ( +

+ {t('no_selected_sensors')} +

+ ) : ( + <> + + {sensors.map((sensor, index) => ( + + +
+ {sensor.title} ( + {sensor.unit}) - {sensor.sensorType} +
+ +
+
+ ))} + + )} +
+
) } diff --git a/app/components/device/new/new-device-stepper.tsx b/app/components/device/new/new-device-stepper.tsx index ceba45c6..0c1c6082 100644 --- a/app/components/device/new/new-device-stepper.tsx +++ b/app/components/device/new/new-device-stepper.tsx @@ -10,7 +10,11 @@ import { AdvancedStep } from './advanced-info' import { DeviceSelectionStep } from './device-info' import { GeneralInfoStep } from './general-info' import { LocationStep } from './location-info' -import { sensorSchema, SensorSelectionStep } from './sensors-info' +import { + customDeviceSchemaUploadSchema, + sensorSchema, + SensorSelectionStep, +} from './sensors-info' import { SummaryInfo } from './summary-info' import { Breadcrumb, @@ -43,6 +47,9 @@ const sensorsSchema = z.object({ selectedSensors: z .array(sensorSchema) .min(1, 'Please select at least one sensor'), + deviceSchema: customDeviceSchemaUploadSchema, + deviceSchemaVersionId: z.string().optional(), + deviceSchemaRegistrySelection: z.any().optional(), }) const advancedSchema = z.record(z.string(), z.any()) diff --git a/app/components/device/new/sensors-info.tsx b/app/components/device/new/sensors-info.tsx index 594a502b..f5a68a63 100644 --- a/app/components/device/new/sensors-info.tsx +++ b/app/components/device/new/sensors-info.tsx @@ -15,6 +15,7 @@ import { Checkbox } from '~/components/ui/checkbox' import { Label } from '~/components/ui/label' import { getSensorsForModel } from '~/lib/model-definitions' import { cn } from '~/lib/utils' +import { uploadedDeviceSchemaV1 } from '~/lib/device-schemas/device-schema-v1' export const sensorSchema = z.object({ title: z.string(), @@ -23,9 +24,17 @@ export const sensorSchema = z.object({ icon: z.string().optional(), image: z.string().optional(), id: z.string().optional(), + sensorWikiType: z.string().optional(), + sensorWikiPhenomenon: z.string().optional(), + sensorWikiUnit: z.string().optional(), }) +export const customDeviceSchemaUploadSchema = uploadedDeviceSchemaV1.optional() + export type Sensor = z.infer +export type CustomDeviceSchemaUpload = z.infer< + typeof customDeviceSchemaUploadSchema +> type SensorGroup = { sensorType: string diff --git a/app/db/drizzle/0043_low_blur.sql b/app/db/drizzle/0043_low_blur.sql new file mode 100644 index 00000000..8dd65f71 --- /dev/null +++ b/app/db/drizzle/0043_low_blur.sql @@ -0,0 +1,42 @@ +CREATE TYPE "public"."device_schema_version_status" AS ENUM('published', 'deprecated');--> statement-breakpoint +CREATE TYPE "public"."device_schema_visibility" AS ENUM('private', 'public');--> statement-breakpoint +CREATE TABLE "device_schema" ( + "id" text PRIMARY KEY NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "description" text, + "tags" text[] DEFAULT ARRAY[]::text[], + "owner_user_id" text NOT NULL, + "visibility" "device_schema_visibility" DEFAULT 'private' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "device_schema_version" ( + "id" text PRIMARY KEY NOT NULL, + "device_schema_id" text NOT NULL, + "version" text NOT NULL, + "format_version" text NOT NULL, + "content" jsonb NOT NULL, + "hash" text NOT NULL, + "status" "device_schema_version_status" DEFAULT 'published' NOT NULL, + "created_by_user_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "published_at" timestamp, + "deprecated_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "device_schema_version_id" text;--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "device_schema_public_id" text;--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "device_schema_id" text;--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "device_schema_name" text;--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "device_schema_version" text;--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "device_schema_hash" text;--> statement-breakpoint +ALTER TABLE "device_schema" ADD CONSTRAINT "device_schema_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "device_schema_version" ADD CONSTRAINT "device_schema_version_device_schema_id_device_schema_id_fk" FOREIGN KEY ("device_schema_id") REFERENCES "public"."device_schema"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "device_schema_version" ADD CONSTRAINT "device_schema_version_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "device_schema_owner_slug_unique" ON "device_schema" USING btree ("owner_user_id","slug");--> statement-breakpoint +CREATE INDEX "device_schema_visibility_idx" ON "device_schema" USING btree ("visibility");--> statement-breakpoint +CREATE UNIQUE INDEX "device_schema_version_unique" ON "device_schema_version" USING btree ("device_schema_id","version");--> statement-breakpoint +CREATE UNIQUE INDEX "device_schema_version_hash_unique" ON "device_schema_version" USING btree ("device_schema_id","hash");--> statement-breakpoint +ALTER TABLE "device" ADD CONSTRAINT "device_device_schema_version_id_device_schema_version_id_fk" FOREIGN KEY ("device_schema_version_id") REFERENCES "public"."device_schema_version"("id") ON DELETE set null ON UPDATE cascade; \ No newline at end of file diff --git a/app/db/drizzle/meta/0043_snapshot.json b/app/db/drizzle/meta/0043_snapshot.json new file mode 100644 index 00000000..ac7e5871 --- /dev/null +++ b/app/db/drizzle/meta/0043_snapshot.json @@ -0,0 +1,1963 @@ +{ + "id": "4f93a158-93df-4fd1-a727-7ceaeaa86961", + "prevId": "86afca58-d04e-41b3-8a69-9a4b4bfe24a4", + "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 + }, + "website": { + "name": "website", + "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 + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "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, + "default": "'custom'" + }, + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "orphaned_at": { + "name": "orphaned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_schema_version_id": { + "name": "device_schema_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_schema_public_id": { + "name": "device_schema_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_schema_id": { + "name": "device_schema_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_schema_name": { + "name": "device_schema_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_schema_version": { + "name": "device_schema_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_schema_hash": { + "name": "device_schema_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "device_user_id_user_id_fk": { + "name": "device_user_id_user_id_fk", + "tableFrom": "device", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_device_schema_version_id_device_schema_version_id_fk": { + "name": "device_device_schema_version_id_device_schema_version_id_fk", + "tableFrom": "device", + "tableTo": "device_schema_version", + "columnsFrom": [ + "device_schema_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "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.device_schema": { + "name": "device_schema", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "device_schema_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "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": { + "device_schema_owner_slug_unique": { + "name": "device_schema_owner_slug_unique", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_schema_visibility_idx": { + "name": "device_schema_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_schema_owner_user_id_user_id_fk": { + "name": "device_schema_owner_user_id_user_id_fk", + "tableFrom": "device_schema", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_schema_version": { + "name": "device_schema_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_schema_id": { + "name": "device_schema_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format_version": { + "name": "format_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_schema_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'published'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deprecated_at": { + "name": "deprecated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_schema_version_unique": { + "name": "device_schema_version_unique", + "columns": [ + { + "expression": "device_schema_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_schema_version_hash_unique": { + "name": "device_schema_version_hash_unique", + "columns": [ + { + "expression": "device_schema_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_schema_version_device_schema_id_device_schema_id_fk": { + "name": "device_schema_version_device_schema_id_device_schema_id_fk", + "tableFrom": "device_schema_version", + "tableTo": "device_schema", + "columnsFrom": [ + "device_schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_schema_version_created_by_user_id_user_id_fk": { + "name": "device_schema_version_created_by_user_id_user_id_fk", + "tableFrom": "device_schema_version", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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": true, + "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.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "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": true + } + }, + "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_user_id_unique": { + "name": "profile_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "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": true + }, + "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 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "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 + }, + "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()" + }, + "accepted_tos_version_id": { + "name": "accepted_tos_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_tos_at": { + "name": "accepted_tos_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_accepted_tos_version_id_tos_version_id_fk": { + "name": "user_accepted_tos_version_id_tos_version_id_fk", + "tableFrom": "user", + "tableTo": "tos_version", + "columnsFrom": [ + "accepted_tos_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_name_unique": { + "name": "user_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "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.integration": { + "name": "integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_key": { + "name": "service_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_slug_unique": { + "name": "integration_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tos_user_state": { + "name": "tos_user_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tos_version_id": { + "name": "tos_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tos_user_state_user_idx": { + "name": "tos_user_state_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tos_user_state_user_id_user_id_fk": { + "name": "tos_user_state_user_id_user_id_fk", + "tableFrom": "tos_user_state", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tos_user_state_tos_version_id_tos_version_id_fk": { + "name": "tos_user_state_tos_version_id_tos_version_id_fk", + "tableFrom": "tos_user_state", + "tableTo": "tos_version", + "columnsFrom": [ + "tos_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tos_user_state_user_id_tos_version_id_pk": { + "name": "tos_user_state_user_id_tos_version_id_pk", + "columns": [ + "user_id", + "tos_version_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tos_version": { + "name": "tos_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "effective_from": { + "name": "effective_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accept_by": { + "name": "accept_by", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tos_version_effective_from_idx": { + "name": "tos_version_effective_from_idx", + "columns": [ + { + "expression": "effective_from", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tos_version_accept_by_idx": { + "name": "tos_version_accept_by_idx", + "columns": [ + { + "expression": "accept_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tos_version_version_unique": { + "name": "tos_version_version_unique", + "nullsNotDistinct": false, + "columns": [ + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_token": { + "name": "action_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "action_token_user_purpose_uq": { + "name": "action_token_user_purpose_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_token_expires_at_idx": { + "name": "action_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "action_token_user_id_user_id_fk": { + "name": "action_token_user_id_user_id_fk", + "tableFrom": "action_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "action_token_token_hash_unique": { + "name": "action_token_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_schema_version_status": { + "name": "device_schema_version_status", + "schema": "public", + "values": [ + "published", + "deprecated" + ] + }, + "public.device_schema_visibility": { + "name": "device_schema_visibility", + "schema": "public", + "values": [ + "private", + "public" + ] + }, + "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/app/db/drizzle/meta/_journal.json b/app/db/drizzle/meta/_journal.json index e81ab9f3..16714a91 100644 --- a/app/db/drizzle/meta/_journal.json +++ b/app/db/drizzle/meta/_journal.json @@ -302,6 +302,13 @@ "when": 1778596447793, "tag": "0042_medical_blackheart", "breakpoints": true + }, + { + "idx": 43, + "version": "7", + "when": 1781681932259, + "tag": "0043_low_blur", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/db/models/device-schema.server.ts b/app/db/models/device-schema.server.ts new file mode 100644 index 00000000..76f73011 --- /dev/null +++ b/app/db/models/device-schema.server.ts @@ -0,0 +1,476 @@ +import { and, desc, eq, ilike, ne, or, sql } from 'drizzle-orm' +import { drizzleClient } from '~/db.server' +import { deviceSchema, deviceSchemaVersion } from '~/db/schema' +import { + uploadedDeviceSchemaV1, + type UploadedDeviceSchemaV1, +} from '~/lib/device-schemas/device-schema-v1' +import { + createDeviceSchemaHash, + slugifyDeviceSchemaName, +} from '~/lib/device-schemas/util' + +export type StoredDeviceSchemaVersion = { + id: string + deviceSchemaId: string + version: string + formatVersion: string + content: UploadedDeviceSchemaV1 + hash: string + schemaSlug: string + schemaName: string +} + +export async function createOrReusePrivateDeviceSchemaVersionFromUpload( + tx: any, + userId: string, + input: unknown, +): Promise { + const parsedSchema = uploadedDeviceSchemaV1.parse(input) + const hash = createDeviceSchemaHash(parsedSchema) + + const [existingVersion] = await tx + .select({ + id: deviceSchemaVersion.id, + deviceSchemaId: deviceSchemaVersion.deviceSchemaId, + version: deviceSchemaVersion.version, + formatVersion: deviceSchemaVersion.formatVersion, + content: deviceSchemaVersion.content, + hash: deviceSchemaVersion.hash, + schemaSlug: deviceSchema.slug, + schemaName: deviceSchema.name, + }) + .from(deviceSchemaVersion) + .innerJoin( + deviceSchema, + eq(deviceSchema.id, deviceSchemaVersion.deviceSchemaId), + ) + .where( + and( + eq(deviceSchema.ownerUserId, userId), + eq(deviceSchemaVersion.hash, hash), + ), + ) + .limit(1) + + if (existingVersion) return existingVersion + + const slugBase = slugifyDeviceSchemaName(parsedSchema.name) + const slug = `${slugBase}-${hash.slice(0, 8)}` + + const [createdSchema] = await tx + .insert(deviceSchema) + .values({ + slug, + name: parsedSchema.name, + description: parsedSchema.description ?? null, + tags: parsedSchema.tags ?? [], + ownerUserId: userId, + visibility: 'private', + }) + .returning() + + if (!createdSchema) throw new Error('Failed to create device schema.') + + const [createdVersion] = await tx + .insert(deviceSchemaVersion) + .values({ + deviceSchemaId: createdSchema.id, + version: parsedSchema.version, + formatVersion: parsedSchema.schemaVersion, + content: parsedSchema, + hash, + status: 'published', + createdByUserId: userId, + publishedAt: new Date(), + }) + .returning() + + if (!createdVersion) + throw new Error('Failed to create device schema version.') + + return { + id: createdVersion.id, + deviceSchemaId: createdVersion.deviceSchemaId, + version: createdVersion.version, + formatVersion: createdVersion.formatVersion, + content: createdVersion.content, + hash: createdVersion.hash, + schemaSlug: createdSchema.slug, + schemaName: createdSchema.name, + } +} + +export async function getPublicDeviceSchemasForUser(userId: string) { + return drizzleClient + .select({ + id: deviceSchema.id, + slug: deviceSchema.slug, + name: deviceSchema.name, + description: deviceSchema.description, + tags: deviceSchema.tags, + createdAt: deviceSchema.createdAt, + versionId: deviceSchemaVersion.id, + version: deviceSchemaVersion.version, + formatVersion: deviceSchemaVersion.formatVersion, + hash: deviceSchemaVersion.hash, + publishedAt: deviceSchemaVersion.publishedAt, + content: deviceSchemaVersion.content, + }) + .from(deviceSchema) + .innerJoin( + deviceSchemaVersion, + eq(deviceSchemaVersion.deviceSchemaId, deviceSchema.id), + ) + .where( + and( + eq(deviceSchema.ownerUserId, userId), + eq(deviceSchema.visibility, 'public'), + eq(deviceSchemaVersion.status, 'published'), + ), + ) + .orderBy(desc(deviceSchemaVersion.publishedAt)) +} + +export async function getVisibleDeviceSchemaVersions(options: { + userId?: string | null + query?: string | null + limit?: number +}) { + const { userId, query, limit = 20 } = options + const visibilityClause = userId + ? or( + eq(deviceSchema.visibility, 'public'), + eq(deviceSchema.ownerUserId, userId), + ) + : eq(deviceSchema.visibility, 'public') + const searchClause = query?.trim() + ? or( + ilike(deviceSchema.name, `%${query.trim()}%`), + ilike(deviceSchema.description, `%${query.trim()}%`), + ilike(deviceSchema.slug, `%${query.trim()}%`), + ) + : undefined + + return drizzleClient + .select({ + id: deviceSchema.id, + slug: deviceSchema.slug, + name: deviceSchema.name, + description: deviceSchema.description, + tags: deviceSchema.tags, + visibility: deviceSchema.visibility, + ownerUserId: deviceSchema.ownerUserId, + versionId: deviceSchemaVersion.id, + version: deviceSchemaVersion.version, + formatVersion: deviceSchemaVersion.formatVersion, + hash: deviceSchemaVersion.hash, + publishedAt: deviceSchemaVersion.publishedAt, + content: deviceSchemaVersion.content, + }) + .from(deviceSchema) + .innerJoin( + deviceSchemaVersion, + eq(deviceSchemaVersion.deviceSchemaId, deviceSchema.id), + ) + .where( + and( + visibilityClause, + eq(deviceSchemaVersion.status, 'published'), + searchClause, + ), + ) + .orderBy(desc(deviceSchemaVersion.publishedAt)) + .limit(limit) +} + +export async function getVisibleDeviceSchemaVersionForCreation( + tx: any, + userId: string, + versionId: string, +): Promise { + const [schemaVersion] = await tx + .select({ + id: deviceSchemaVersion.id, + deviceSchemaId: deviceSchemaVersion.deviceSchemaId, + version: deviceSchemaVersion.version, + formatVersion: deviceSchemaVersion.formatVersion, + content: deviceSchemaVersion.content, + hash: deviceSchemaVersion.hash, + schemaSlug: deviceSchema.slug, + schemaName: deviceSchema.name, + }) + .from(deviceSchemaVersion) + .innerJoin( + deviceSchema, + eq(deviceSchema.id, deviceSchemaVersion.deviceSchemaId), + ) + .where( + and( + eq(deviceSchemaVersion.id, versionId), + eq(deviceSchemaVersion.status, 'published'), + or( + eq(deviceSchema.visibility, 'public'), + eq(deviceSchema.ownerUserId, userId), + ), + ), + ) + .limit(1) + + return schemaVersion +} + +export async function getOwnedDeviceSchemasWithVersions(userId: string) { + const rows = await drizzleClient + .select({ + id: deviceSchema.id, + slug: deviceSchema.slug, + name: deviceSchema.name, + description: deviceSchema.description, + tags: deviceSchema.tags, + visibility: deviceSchema.visibility, + createdAt: deviceSchema.createdAt, + updatedAt: deviceSchema.updatedAt, + versionId: deviceSchemaVersion.id, + version: deviceSchemaVersion.version, + formatVersion: deviceSchemaVersion.formatVersion, + hash: deviceSchemaVersion.hash, + status: deviceSchemaVersion.status, + createdAtVersion: deviceSchemaVersion.createdAt, + publishedAt: deviceSchemaVersion.publishedAt, + deprecatedAt: deviceSchemaVersion.deprecatedAt, + content: deviceSchemaVersion.content, + }) + .from(deviceSchema) + .innerJoin( + deviceSchemaVersion, + eq(deviceSchemaVersion.deviceSchemaId, deviceSchema.id), + ) + .where(eq(deviceSchema.ownerUserId, userId)) + .orderBy(desc(deviceSchema.updatedAt), desc(deviceSchemaVersion.createdAt)) + + const grouped = new Map< + string, + { + id: string + slug: string + name: string + description: string | null + tags: string[] | null + visibility: 'private' | 'public' + createdAt: Date + updatedAt: Date + versions: Array<{ + id: string + version: string + formatVersion: string + hash: string + status: 'published' | 'deprecated' + createdAt: Date + publishedAt: Date | null + deprecatedAt: Date | null + content: UploadedDeviceSchemaV1 + }> + } + >() + + for (const row of rows) { + const schema = grouped.get(row.id) ?? { + id: row.id, + slug: row.slug, + name: row.name, + description: row.description, + tags: row.tags, + visibility: row.visibility, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + versions: [], + } + + schema.versions.push({ + id: row.versionId, + version: row.version, + formatVersion: row.formatVersion, + hash: row.hash, + status: row.status, + createdAt: row.createdAtVersion, + publishedAt: row.publishedAt, + deprecatedAt: row.deprecatedAt, + content: row.content, + }) + + grouped.set(row.id, schema) + } + + return Array.from(grouped.values()) +} + +export async function publishNewDeviceSchemaVersion( + userId: string, + schemaId: string, + input: unknown, +) { + const parsedSchema = uploadedDeviceSchemaV1.parse(input) + const hash = createDeviceSchemaHash(parsedSchema) + + return drizzleClient.transaction(async (tx) => { + const [ownedSchema] = await tx + .select({ + id: deviceSchema.id, + name: deviceSchema.name, + }) + .from(deviceSchema) + .where( + and( + eq(deviceSchema.id, schemaId), + eq(deviceSchema.ownerUserId, userId), + ), + ) + .limit(1) + + if (!ownedSchema) { + throw new Error('Device schema not found.') + } + + const [referenceVersion] = await tx + .select({ + content: deviceSchemaVersion.content, + }) + .from(deviceSchemaVersion) + .where(eq(deviceSchemaVersion.deviceSchemaId, schemaId)) + .limit(1) + + if ( + referenceVersion?.content.id && + referenceVersion.content.id !== parsedSchema.id + ) { + throw new Error('Uploaded schema id does not match this schema.') + } + + const [existingVersion] = await tx + .select({ id: deviceSchemaVersion.id }) + .from(deviceSchemaVersion) + .where( + and( + eq(deviceSchemaVersion.deviceSchemaId, schemaId), + eq(deviceSchemaVersion.version, parsedSchema.version), + ), + ) + .limit(1) + + if (existingVersion) { + throw new Error('This schema version already exists.') + } + + const [existingHash] = await tx + .select({ id: deviceSchemaVersion.id }) + .from(deviceSchemaVersion) + .where( + and( + eq(deviceSchemaVersion.deviceSchemaId, schemaId), + eq(deviceSchemaVersion.hash, hash), + ), + ) + .limit(1) + + if (existingHash) { + throw new Error('This schema content has already been published.') + } + + const now = new Date() + + const [createdVersion] = await tx + .insert(deviceSchemaVersion) + .values({ + deviceSchemaId: schemaId, + version: parsedSchema.version, + formatVersion: parsedSchema.schemaVersion, + content: parsedSchema, + hash, + status: 'published', + createdByUserId: userId, + publishedAt: now, + }) + .returning() + + if (!createdVersion) { + throw new Error('Failed to publish schema version.') + } + + await tx + .update(deviceSchemaVersion) + .set({ + status: 'deprecated', + deprecatedAt: now, + }) + .where( + and( + eq(deviceSchemaVersion.deviceSchemaId, schemaId), + eq(deviceSchemaVersion.status, 'published'), + ne(deviceSchemaVersion.id, createdVersion.id), + ), + ) + + await tx + .update(deviceSchema) + .set({ updatedAt: sql`NOW()` }) + .where(eq(deviceSchema.id, schemaId)) + + return createdVersion + }) +} + +export async function updateDeviceSchemaVisibility( + userId: string, + schemaId: string, + visibility: 'private' | 'public', +) { + const [updatedSchema] = await drizzleClient + .update(deviceSchema) + .set({ visibility }) + .where( + and(eq(deviceSchema.id, schemaId), eq(deviceSchema.ownerUserId, userId)), + ) + .returning() + + return updatedSchema +} + +export async function getSharedDeviceSchemaVersion( + versionId: string, + userId?: string | null, +) { + const [schemaVersion] = await drizzleClient + .select({ + slug: deviceSchema.slug, + name: deviceSchema.name, + visibility: deviceSchema.visibility, + version: deviceSchemaVersion.version, + content: deviceSchemaVersion.content, + }) + .from(deviceSchemaVersion) + .innerJoin( + deviceSchema, + eq(deviceSchema.id, deviceSchemaVersion.deviceSchemaId), + ) + .where( + and( + eq(deviceSchemaVersion.id, versionId), + userId + ? or( + and( + eq(deviceSchema.visibility, 'public'), + eq(deviceSchemaVersion.status, 'published'), + ), + eq(deviceSchema.ownerUserId, userId), + ) + : and( + eq(deviceSchema.visibility, 'public'), + eq(deviceSchemaVersion.status, 'published'), + ), + ), + ) + .limit(1) + + return schemaVersion +} diff --git a/app/db/models/device.server.ts b/app/db/models/device.server.ts index 01a148b0..11be6bea 100644 --- a/app/db/models/device.server.ts +++ b/app/db/models/device.server.ts @@ -38,6 +38,10 @@ import { messages as NewSenseboxDeviceMessages } from '~/emails/new-device-sense import { createDeviceApiKey } from '~/lib/jwt' import { sendMail } from '~/lib/mail.server' import { getSensorsForModel } from '~/lib/model-definitions' +import { + createOrReusePrivateDeviceSchemaVersionFromUpload, + getVisibleDeviceSchemaVersionForCreation, +} from './device-schema.server' const BASE_DEVICE_COLUMNS = { id: true, @@ -62,6 +66,12 @@ const BASE_DEVICE_COLUMNS = { sensorWikiModel: true, public: true, userId: true, + deviceSchemaVersionId: true, + deviceSchemaPublicId: true, + deviceSchemaId: true, + deviceSchemaName: true, + deviceSchemaVersion: true, + deviceSchemaHash: true, } as const const DEVICE_COLUMNS_WITH_SENSORS = { @@ -205,10 +215,58 @@ export function getDeviceWithoutSensors({ id }: Pick) { useAuth: true, model: true, apiKey: true, + deviceSchemaVersionId: true, + deviceSchemaPublicId: true, + deviceSchemaId: true, + deviceSchemaName: true, + deviceSchemaVersion: true, + deviceSchemaHash: true, }, }) } +export async function detachDeviceSchema({ + id, + userId, +}: Pick) { + const [existingDevice] = await drizzleClient + .select() + .from(device) + .where(and(eq(device.id, id), eq(device.userId, userId))) + .limit(1) + + if (!existingDevice) { + throw new DeviceUpdateError(`Device ${id} not found`, 404) + } + + assertDeviceIsMutable(existingDevice) + + const tags = (existingDevice.tags ?? []).filter( + (tag) => !tag.startsWith('schema:'), + ) + + const [updatedDevice] = await drizzleClient + .update(device) + .set({ + tags, + deviceSchemaVersionId: null, + deviceSchemaPublicId: null, + deviceSchemaId: null, + deviceSchemaName: null, + deviceSchemaVersion: null, + deviceSchemaHash: null, + updatedAt: sql`NOW()`, + }) + .where(and(eq(device.id, id), eq(device.userId, userId))) + .returning() + + if (!updatedDevice) { + throw new DeviceUpdateError(`Device ${id} not found`, 404) + } + + return updatedDevice +} + export type DeviceWithoutSensors = Awaited< ReturnType > @@ -821,6 +879,9 @@ export async function createDevice(deviceData: any, userId: string) { // Determine sensors to use let sensorsToAdd = deviceData.sensors + let storedDeviceSchemaVersion = null + const isCustomDevice = + !deviceData.model || deviceData.model?.toLowerCase() === 'custom' // If model and sensors are both specified, reject (backwards compatibility) if ( @@ -856,10 +917,45 @@ export async function createDevice(deviceData: any, userId: string) { } } - if (deviceData.model?.toLowerCase() === 'custom' && deviceData.sensors) { + if (isCustomDevice && deviceData.sensors) { sensorsToAdd = deviceData.sensors ?? [] } + if (isCustomDevice && deviceData.deviceSchema) { + storedDeviceSchemaVersion = + await createOrReusePrivateDeviceSchemaVersionFromUpload( + tx, + userId, + deviceData.deviceSchema, + ) + + sensorsToAdd = storedDeviceSchemaVersion.content.sensors + } + + if (isCustomDevice && deviceData.deviceSchemaVersionId) { + storedDeviceSchemaVersion = + await getVisibleDeviceSchemaVersionForCreation( + tx, + userId, + deviceData.deviceSchemaVersionId, + ) + + if (!storedDeviceSchemaVersion) { + throw new Error('Device schema version not found.') + } + + sensorsToAdd = storedDeviceSchemaVersion.content.sensors + } + + const schemaTag = storedDeviceSchemaVersion + ? `schema:${storedDeviceSchemaVersion.schemaSlug}` + : null + const tags = [...(deviceData.tags ?? [])] + + if (schemaTag && !tags.includes(schemaTag)) { + tags.push(schemaTag) + } + // Create the device const [createdDevice] = await tx .insert(device) @@ -867,7 +963,7 @@ export async function createDevice(deviceData: any, userId: string) { id: deviceData.id, useAuth: deviceData.useAuth ?? true, model: deviceData.model, - tags: deviceData.tags, + tags, userId: userId, name: deviceData.name, description: deviceData.description, @@ -880,6 +976,12 @@ export async function createDevice(deviceData: any, userId: string) { : null, latitude: deviceData.latitude, longitude: deviceData.longitude, + deviceSchemaVersionId: storedDeviceSchemaVersion?.id, + deviceSchemaPublicId: storedDeviceSchemaVersion?.schemaSlug, + deviceSchemaId: storedDeviceSchemaVersion?.content.id, + deviceSchemaName: storedDeviceSchemaVersion?.schemaName, + deviceSchemaVersion: storedDeviceSchemaVersion?.version, + deviceSchemaHash: storedDeviceSchemaVersion?.hash, }) .returning() @@ -894,7 +996,7 @@ export async function createDevice(deviceData: any, userId: string) { Array.isArray(sensorsToAdd) && sensorsToAdd.length > 0 ) { - for (const sensorData of sensorsToAdd) { + for (const [index, sensorData] of sensorsToAdd.entries()) { const [newSensor] = await tx .insert(sensor) .values({ @@ -902,7 +1004,14 @@ export async function createDevice(deviceData: any, userId: string) { unit: sensorData.unit, sensorType: sensorData.sensorType, icon: sensorData.icon, + sensorWikiType: sensorData.sensorWikiType, + sensorWikiPhenomenon: sensorData.sensorWikiPhenomenon, + sensorWikiUnit: sensorData.sensorWikiUnit, deviceId: createdDevice.id, + data: storedDeviceSchemaVersion + ? { deviceSchemaSensorId: sensorData.id } + : sensorData.data, + order: index, }) .returning() diff --git a/app/db/models/sensor.server.ts b/app/db/models/sensor.server.ts index cdb1c7e7..9d1b7a99 100644 --- a/app/db/models/sensor.server.ts +++ b/app/db/models/sensor.server.ts @@ -69,7 +69,7 @@ export function getSensors(deviceId: Sensor['deviceId']) { export function getSensorsFromDevice(deviceId: Sensor['deviceId']) { return drizzleClient.query.sensor.findMany({ where: (sensor, { eq }) => eq(sensor.deviceId, deviceId), - orderBy: (sensor, { asc }) => [asc(sensor.order)], + orderBy: (sensor, { asc }) => [asc(sensor.order), asc(sensor.createdAt)], }) } @@ -167,13 +167,17 @@ export function addNewSensor({ title, unit, sensorType, + icon, deviceId, order, -}: Pick) { +}: Pick & { + icon?: Sensor['icon'] +}) { return drizzleClient.insert(sensor).values({ title, unit, sensorType, + icon, deviceId, order, }) @@ -184,17 +188,29 @@ export function updateSensor({ title, unit, sensorType, + icon, + data, order, -}: Pick) { - return drizzleClient - .update(sensor) - .set({ - title, - unit, - sensorType, - order, - }) - .where(eq(sensor.id, id)) +}: Pick & { + icon?: Sensor['icon'] + data?: Sensor['data'] +}) { + const setColumns: Partial = { + title, + unit, + sensorType, + order, + } + + if (icon !== undefined) { + setColumns.icon = icon + } + + if (data !== undefined) { + setColumns.data = data + } + + return drizzleClient.update(sensor).set(setColumns).where(eq(sensor.id, id)) } // return first sensor with its device name diff --git a/app/db/schema/device-schema.ts b/app/db/schema/device-schema.ts new file mode 100644 index 00000000..12e27d75 --- /dev/null +++ b/app/db/schema/device-schema.ts @@ -0,0 +1,126 @@ +import { createId } from '@paralleldrive/cuid2' +import { relations, sql, type InferSelectModel } from 'drizzle-orm' +import { + index, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, +} from 'drizzle-orm/pg-core' +import { user } from './user' +import { type UploadedDeviceSchemaV1 } from '~/lib/device-schemas/device-schema-v1' + +export const deviceSchemaVisibilityEnum = pgEnum('device_schema_visibility', [ + 'private', + 'public', +]) + +export const deviceSchemaVersionStatusEnum = pgEnum( + 'device_schema_version_status', + ['published', 'deprecated'], +) + +export const deviceSchema = pgTable( + 'device_schema', + { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + slug: text('slug').notNull(), + name: text('name').notNull(), + description: text('description'), + tags: text('tags') + .array() + .default(sql`ARRAY[]::text[]`), + ownerUserId: text('owner_user_id') + .notNull() + .references(() => user.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + visibility: deviceSchemaVisibilityEnum('visibility') + .default('private') + .notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => [ + uniqueIndex('device_schema_owner_slug_unique').on( + table.ownerUserId, + table.slug, + ), + index('device_schema_visibility_idx').on(table.visibility), + ], +) + +export const deviceSchemaVersion = pgTable( + 'device_schema_version', + { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + deviceSchemaId: text('device_schema_id') + .notNull() + .references(() => deviceSchema.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + version: text('version').notNull(), + formatVersion: text('format_version').notNull(), + content: jsonb('content').$type().notNull(), + hash: text('hash').notNull(), + status: deviceSchemaVersionStatusEnum('status') + .default('published') + .notNull(), + createdByUserId: text('created_by_user_id') + .notNull() + .references(() => user.id, { + onDelete: 'restrict', + }), + createdAt: timestamp('created_at').defaultNow().notNull(), + publishedAt: timestamp('published_at'), + deprecatedAt: timestamp('deprecated_at'), + }, + (table) => [ + uniqueIndex('device_schema_version_unique').on( + table.deviceSchemaId, + table.version, + ), + uniqueIndex('device_schema_version_hash_unique').on( + table.deviceSchemaId, + table.hash, + ), + ], +) + +export const deviceSchemaRelations = relations( + deviceSchema, + ({ one, many }) => ({ + owner: one(user, { + fields: [deviceSchema.ownerUserId], + references: [user.id], + }), + versions: many(deviceSchemaVersion), + }), +) + +export const deviceSchemaVersionRelations = relations( + deviceSchemaVersion, + ({ one }) => ({ + schema: one(deviceSchema, { + fields: [deviceSchemaVersion.deviceSchemaId], + references: [deviceSchema.id], + }), + createdBy: one(user, { + fields: [deviceSchemaVersion.createdByUserId], + references: [user.id], + }), + }), +) + +export type DeviceSchema = InferSelectModel +export type DeviceSchemaVersion = InferSelectModel diff --git a/app/db/schema/device.ts b/app/db/schema/device.ts index 022f7708..a593a401 100644 --- a/app/db/schema/device.ts +++ b/app/db/schema/device.ts @@ -21,6 +21,7 @@ import { location } from './location' import { logEntry } from './log-entry' import { sensor } from './sensor' import { user } from './user' +import { deviceSchemaVersion } from './device-schema' /** * Table @@ -58,6 +59,18 @@ export const device = pgTable('device', { onDelete: 'cascade', onUpdate: 'cascade', }), + deviceSchemaVersionId: text('device_schema_version_id').references( + () => deviceSchemaVersion.id, + { + onDelete: 'set null', + onUpdate: 'cascade', + }, + ), + deviceSchemaPublicId: text('device_schema_public_id'), + deviceSchemaId: text('device_schema_id'), + deviceSchemaName: text('device_schema_name'), + deviceSchemaVersion: text('device_schema_version'), + deviceSchemaHash: text('device_schema_hash'), }) // Many-to-many relation between device - location diff --git a/app/db/schema/index.ts b/app/db/schema/index.ts index 3f74b939..b7d498d9 100644 --- a/app/db/schema/index.ts +++ b/app/db/schema/index.ts @@ -1,4 +1,5 @@ export * from './device' +export * from './device-schema' export * from './enum' export * from './measurement' export * from './password' diff --git a/app/hooks/use-copy-to-clipboard.ts b/app/hooks/use-copy-to-clipboard.ts index 20cc5dbf..0d8a8987 100644 --- a/app/hooks/use-copy-to-clipboard.ts +++ b/app/hooks/use-copy-to-clipboard.ts @@ -1,7 +1,9 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' export function useCopyToClipboard(resetAfter = 2000) { const [copiedToClipboard, setCopiedToClipboard] = useState(false) + const [copiedValue, setCopiedValue] = useState(null) + const resetTimeoutRef = useRef(null) const copyToClipboard = useCallback( async (value: string | undefined | null) => { @@ -10,9 +12,15 @@ export function useCopyToClipboard(resetAfter = 2000) { await navigator.clipboard.writeText(value) setCopiedToClipboard(true) + setCopiedValue(value) - window.setTimeout(() => { + if (resetTimeoutRef.current) { + window.clearTimeout(resetTimeoutRef.current) + } + + resetTimeoutRef.current = window.setTimeout(() => { setCopiedToClipboard(false) + setCopiedValue(null) }, resetAfter) return true @@ -22,6 +30,7 @@ export function useCopyToClipboard(resetAfter = 2000) { return { copiedToClipboard, + copiedValue, copyToClipboard, } } diff --git a/app/lib/device-schemas/device-schema-v1.ts b/app/lib/device-schemas/device-schema-v1.ts new file mode 100644 index 00000000..34c24422 --- /dev/null +++ b/app/lib/device-schemas/device-schema-v1.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' + +export const deviceSchemaSensorSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + unit: z.string().min(1), + sensorType: z.string().min(1), + icon: z.string().optional(), + sensorWikiType: z.string().optional(), + sensorWikiPhenomenon: z.string().optional(), + sensorWikiUnit: z.string().optional(), +}) + +export const uploadedDeviceSchemaV1 = z + .object({ + schemaType: z.literal('opensensemap.deviceSchema'), + schemaVersion: z.literal('1.0.0'), + id: z.string().min(1), + name: z.string().min(1), + version: z.string().min(1), + description: z.string().optional(), + tags: z.array(z.string()).optional().default([]), + sensors: z.array(deviceSchemaSensorSchema).min(1), + }) + .strict() + +export type UploadedDeviceSchemaV1 = z.infer diff --git a/app/lib/device-schemas/util.ts b/app/lib/device-schemas/util.ts new file mode 100644 index 00000000..0fc2a694 --- /dev/null +++ b/app/lib/device-schemas/util.ts @@ -0,0 +1,31 @@ +import { createHash } from 'crypto' + +export function slugifyDeviceSchemaName(name: string) { + return ( + name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'device-schema' + ) +} + +function sortForStableHash(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortForStableHash) + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, val]) => [key, sortForStableHash(val)]), + ) + } + + return value +} + +export function createDeviceSchemaHash(content: unknown) { + return createHash('sha256') + .update(JSON.stringify(sortForStableHash(content))) + .digest('hex') +} diff --git a/app/routes/device.$deviceId.edit.sensors.tsx b/app/routes/device.$deviceId.edit.sensors.tsx index 387517b2..50dba9a2 100644 --- a/app/routes/device.$deviceId.edit.sensors.tsx +++ b/app/routes/device.$deviceId.edit.sensors.tsx @@ -34,6 +34,11 @@ import { getSensorsFromDevice, updateSensor, } from '~/db/models/sensor.server' +import { + detachDeviceSchema, + getDeviceWithoutSensors, +} from '~/db/models/device.server' +import { getSharedDeviceSchemaVersion } from '~/db/models/device-schema.server' import { assignIcon, getIcon, iconsList } from '~/lib/sensoricons' import { getUserId } from '~/services/session-service.server' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' @@ -51,9 +56,30 @@ export async function loader({ request, params }: Route.LoaderArgs) { if (typeof deviceID !== 'string') { return 'deviceID not found' } - const rawSensorsData = await getSensorsFromDevice(deviceID) + const device = await getDeviceWithoutSensors({ id: deviceID }) + + if (!device || device.userId !== userId) { + return redirect('/') + } - return rawSensorsData as any + const [rawSensorsData, deviceSchema] = await Promise.all([ + getSensorsFromDevice(deviceID), + device.deviceSchemaVersionId + ? getSharedDeviceSchemaVersion(device.deviceSchemaVersionId, userId) + : Promise.resolve(undefined), + ]) + + return { + sensors: rawSensorsData, + deviceSchema: deviceSchema + ? { + name: device.deviceSchemaName ?? deviceSchema.name, + version: device.deviceSchemaVersion ?? deviceSchema.version, + hash: device.deviceSchemaHash, + sensors: deviceSchema.content.sensors, + } + : null, + } as any } //***************************************************** @@ -62,16 +88,109 @@ export async function action({ request, params }: Route.ActionArgs) { if (!userId) return redirect('/') const formData = await request.formData() - const { updatedSensorsData } = Object.fromEntries(formData) - - if (typeof updatedSensorsData !== 'string') { - return { isUpdated: false } - } + const { intent, updatedSensorsData } = Object.fromEntries(formData) const deviceId = params.deviceId invariant(deviceId, 'deviceID not found!') + const device = await getDeviceWithoutSensors({ id: deviceId }) + if (!device || device.userId !== userId) return redirect('/') + + if (intent === 'detach-schema') { + await detachDeviceSchema({ id: deviceId, userId }) + return { isUpdated: true, isDetached: true } + } + + if (typeof updatedSensorsData !== 'string') { + return { isUpdated: false, message: 'No sensor data submitted.' } + } + const updatedSensorsDataJson = JSON.parse(updatedSensorsData) + const currentSensors = await getSensorsFromDevice(deviceId) + const currentSensorsById = new Map( + currentSensors.map((sensor) => [sensor.id, sensor]), + ) + const deviceSchema = device.deviceSchemaVersionId + ? await getSharedDeviceSchemaVersion(device.deviceSchemaVersionId, userId) + : null + + if (deviceSchema) { + if (currentSensors.length !== deviceSchema.content.sensors.length) { + return { + isUpdated: false, + message: + 'This device no longer matches its schema. Detach it from the schema before editing sensors.', + } + } + + const schemaSensorsById = new Map( + deviceSchema.content.sensors.map((schemaSensor) => [ + schemaSensor.id, + schemaSensor, + ]), + ) + const schemaSensorsByExistingSensorId = new Map( + currentSensors.map((sensor, index) => { + const schemaSensorId = + sensor.data && + typeof sensor.data === 'object' && + !Array.isArray(sensor.data) + ? (sensor.data as { deviceSchemaSensorId?: unknown }) + .deviceSchemaSensorId + : null + + return [ + sensor.id, + typeof schemaSensorId === 'string' + ? (schemaSensorsById.get(schemaSensorId) ?? + deviceSchema.content.sensors[index]) + : deviceSchema.content.sensors[index], + ] + }), + ) + + for (const [index, submittedSensor] of updatedSensorsDataJson.entries()) { + const sensorId = submittedSensor?.id + const existingSensor = + typeof sensorId === 'string' ? currentSensorsById.get(sensorId) : null + const schemaSensor = + typeof sensorId === 'string' + ? schemaSensorsByExistingSensorId.get(sensorId) + : null + + if ( + submittedSensor?.new === true || + submittedSensor?.deleted === true || + !existingSensor || + !schemaSensor + ) { + return { + isUpdated: false, + message: + 'Schema-backed sensors cannot be added or deleted. Detach the device from its schema first.', + } + } + + await updateSensor({ + id: existingSensor.id, + title: schemaSensor.title, + unit: schemaSensor.unit, + sensorType: schemaSensor.sensorType, + icon: submittedSensor.icon ?? existingSensor.icon, + data: { + ...(existingSensor.data && + typeof existingSensor.data === 'object' && + !Array.isArray(existingSensor.data) + ? existingSensor.data + : {}), + deviceSchemaSensorId: schemaSensor.id, + }, + order: index, + }) + } + + return { isUpdated: true } + } for (const [index, sensor] of updatedSensorsDataJson.entries()) { if (sensor?.new === true && sensor?.edited === true) { @@ -79,24 +198,32 @@ export async function action({ request, params }: Route.ActionArgs) { title: sensor.title, unit: sensor.unit, sensorType: sensor.sensorType, + icon: sensor.icon, deviceId, order: index, }) } else if (sensor?.deleted === true) { + if (!currentSensorsById.has(sensor.id)) { + return { isUpdated: false, message: 'Sensor not found.' } + } await deleteSensor(sensor.id) } else if (!sensor?.new) { + if (!currentSensorsById.has(sensor.id)) { + return { isUpdated: false, message: 'Sensor not found.' } + } await updateSensor({ id: sensor.id, title: sensor.title, unit: sensor.unit, sensorType: sensor.sensorType, + icon: sensor.icon, order: index, }) } } - const currentSensors = await getSensorsFromDevice(deviceId) - const validSensorIds = currentSensors + const nextSensors = await getSensorsFromDevice(deviceId) + const validSensorIds = nextSensors .map((sensor: any) => sensor._id || sensor.id) .filter(Boolean) @@ -123,7 +250,12 @@ export default function EditBoxSensors() { const [copiedSensorId, setCopiedSensorId] = React.useState( null, ) - const [sensorsData, setSensorsData] = useState(data) + const originalSensorsData = Array.isArray(data) ? data : data.sensors + const deviceSchema = Array.isArray(data) ? null : data.deviceSchema + const isSchemaBacked = !!deviceSchema + const isSchemaOutOfSync = + isSchemaBacked && originalSensorsData.length !== deviceSchema.sensors.length + const [sensorsData, setSensorsData] = useState(originalSensorsData) /* temp impl. until figuring out how to updating state of nested objects */ const [tepmState, setTepmState] = useState(false) @@ -150,8 +282,18 @@ export default function EditBoxSensors() { delete sensor.editing } } + } else if (actionData?.message) { + toast({ + title: t('save_failed'), + description: actionData.message, + variant: 'destructive', + }) } - }, [actionData, setToastOpen]) // eslint-disable-line react-hooks/exhaustive-deps + }, [actionData, setToastOpen, toast, t]) // eslint-disable-line react-hooks/exhaustive-deps + + React.useEffect(() => { + setSensorsData(originalSensorsData) + }, [originalSensorsData]) const handleCopySensorId = React.useCallback( async (sensorId: string | null) => { @@ -202,6 +344,7 @@ export default function EditBoxSensors() { + {isSchemaBacked && ( + + )} +
+ {isSchemaBacked && ( +
+

+ {t('schema_notice_title', { + name: deviceSchema.name, + version: deviceSchema.version, + })} +

+

{t('schema_notice_text')}

+ {isSchemaOutOfSync && ( +

+ {t('schema_out_of_sync')} +

+ )} +
+ )} +
    {sensorsData?.map((sensor: any, index: number) => { + const isSchemaSensor = isSchemaBacked && !sensor?.new + const canEditSchemaFields = !isSchemaSensor + return (
  • + {isSchemaSensor && ( +
    + {t('schema_fields_locked')} +
    + )}
    + + {deviceSchemas.length > 0 && ( +
    +
    + {t('device_schemas')} +
    +
    + {deviceSchemas.map((schema) => ( +
    +
    +
    +

    {schema.name}

    + v{schema.version} + + {schema.content.sensors.length} {t('sensors')} + +
    + {schema.description && ( +

    + {schema.description} +

    + )} +
    + +
    + ))} +
    +
    + )}
diff --git a/app/routes/resources.device-schema.$versionId.tsx b/app/routes/resources.device-schema.$versionId.tsx new file mode 100644 index 00000000..53abbd00 --- /dev/null +++ b/app/routes/resources.device-schema.$versionId.tsx @@ -0,0 +1,29 @@ +import { type Route } from './+types/resources.device-schema.$versionId' +import { getSharedDeviceSchemaVersion } from '~/db/models/device-schema.server' +import { getUserId } from '~/services/session-service.server' + +function filenameForSchema(slug: string, version: string) { + return `${slug}-${version}.json`.replace(/[^a-zA-Z0-9._-]+/g, '-') +} + +export async function loader({ request, params }: Route.LoaderArgs) { + const userId = await getUserId(request) + const schemaVersion = await getSharedDeviceSchemaVersion( + params.versionId as string, + userId, + ) + + if (!schemaVersion) { + throw new Response('Not found', { status: 404 }) + } + + return new Response(JSON.stringify(schemaVersion.content, null, 2), { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filenameForSchema( + schemaVersion.slug, + schemaVersion.version, + )}"`, + }, + }) +} diff --git a/app/routes/resources.device-schemas.tsx b/app/routes/resources.device-schemas.tsx new file mode 100644 index 00000000..aa20cdf2 --- /dev/null +++ b/app/routes/resources.device-schemas.tsx @@ -0,0 +1,28 @@ +import { type Route } from './+types/resources.device-schemas' +import { getVisibleDeviceSchemaVersions } from '~/db/models/device-schema.server' +import { getUserId } from '~/services/session-service.server' + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url) + const userId = await getUserId(request) + const query = url.searchParams.get('q') + const schemas = await getVisibleDeviceSchemaVersions({ userId, query }) + + return Response.json({ + schemas: schemas.map((schema) => ({ + id: schema.id, + slug: schema.slug, + name: schema.name, + description: schema.description, + tags: schema.tags ?? [], + visibility: schema.visibility, + versionId: schema.versionId, + version: schema.version, + formatVersion: schema.formatVersion, + hash: schema.hash, + publishedAt: schema.publishedAt, + isOwner: userId === schema.ownerUserId, + content: schema.content, + })), + }) +} diff --git a/app/routes/settings.profile.tsx b/app/routes/settings.profile.tsx index ac8f0007..23bf1108 100644 --- a/app/routes/settings.profile.tsx +++ b/app/routes/settings.profile.tsx @@ -1,10 +1,11 @@ import { CopyIcon, CopyCheckIcon, InfoIcon } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Link, Outlet, useLoaderData } from 'react-router' +import { Form, Link, Outlet, useActionData, useLoaderData } from 'react-router' import { type Route } from './+types/settings.profile' import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar' +import { Badge } from '~/components/ui/badge' import { Button } from '~/components/ui/button' import { Card, @@ -24,6 +25,11 @@ import { } from '~/components/ui/tooltip' import { useToast } from '~/components/ui/use-toast' +import { + getOwnedDeviceSchemasWithVersions, + publishNewDeviceSchemaVersion, + updateDeviceSchemaVisibility, +} from '~/db/models/device-schema.server' import { getProfileByUserId, updateProfile } from '~/db/models/profile.server' import { getUserById } from '~/db/models/user.server' import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard' @@ -38,9 +44,10 @@ import { AutosaveStatusText } from '~/components/autosave-status.text' export async function loader({ request }: Route.LoaderArgs) { const userId = await requireUserId(request) - const [user, profile] = await Promise.all([ + const [user, profile, deviceSchemas] = await Promise.all([ getUserById(userId), getProfileByUserId(userId), + getOwnedDeviceSchemasWithVersions(userId), ]) if (!user || !profile) { @@ -59,6 +66,20 @@ export async function loader({ request }: Route.LoaderArgs) { return { profile, publicProfileUrl, + deviceSchemas: deviceSchemas.map((schema) => ({ + ...schema, + versions: schema.versions.map((version) => { + const downloadUrl = new URL( + `/resources/device-schema/${version.id}`, + request.url, + ).toString() + + return { + ...version, + downloadUrl, + } + }), + })), } } @@ -79,9 +100,31 @@ export type ProfileActionData = message: string } +type DeviceSchemaActionData = + | { + intent: 'update-device-schema-visibility' + success: true + } + | { + intent: 'update-device-schema-visibility' + success: false + message: string + } + | { + intent: 'publish-device-schema-version' + success: true + } + | { + intent: 'publish-device-schema-version' + success: false + message: string + } + +type SettingsProfileActionData = ProfileActionData | DeviceSchemaActionData + export async function action({ request, -}: Route.ActionArgs): Promise { +}: Route.ActionArgs): Promise { const userId = await requireUserId(request) const profile = await getProfileByUserId(userId) @@ -96,8 +139,70 @@ export async function action({ const formData = await request.formData() const intent = String(formData.get('intent') ?? '') - const displayName = String(formData.get('displayName') ?? '').trim() - const isPublic = formData.get('isPublic') === 'true' + + if (intent === 'update-device-schema-visibility') { + const schemaId = String(formData.get('schemaId') ?? '') + const visibility = String(formData.get('visibility') ?? '') + + if (!schemaId || (visibility !== 'private' && visibility !== 'public')) { + return { + intent, + success: false, + message: 'Invalid schema visibility update.', + } + } + + const updatedSchema = await updateDeviceSchemaVisibility( + userId, + schemaId, + visibility, + ) + + if (!updatedSchema) { + return { + intent, + success: false, + message: 'Device schema could not be updated.', + } + } + + return { + intent, + success: true, + } + } + + if (intent === 'publish-device-schema-version') { + const schemaId = String(formData.get('schemaId') ?? '') + const schemaFile = formData.get('schemaFile') + + if (!schemaId || !(schemaFile instanceof File) || schemaFile.size === 0) { + return { + intent, + success: false, + message: 'Please choose a schema JSON file.', + } + } + + try { + const parsedSchema = JSON.parse(await schemaFile.text()) + await publishNewDeviceSchemaVersion(userId, schemaId, parsedSchema) + + return { + intent, + success: true, + } + } catch (error) { + return { + intent, + success: false, + message: + error instanceof Error + ? error.message + : 'Schema version could not be published.', + } + } + } if (intent !== 'autosave-profile') { return { @@ -107,6 +212,9 @@ export async function action({ } } + const displayName = String(formData.get('displayName') ?? '').trim() + const isPublic = formData.get('isPublic') === 'true' + if (displayName.length < 3 || displayName.length > 40) { return { intent: 'autosave-profile', @@ -144,6 +252,7 @@ type ProfileAutosaveValues = { export default function EditUserProfilePage() { const data = useLoaderData() + const actionData = useActionData() const [displayName, setDisplayName] = useState(data.profile.displayName) const [isPublic, setIsPublic] = useState(data.profile.public ?? false) @@ -152,7 +261,7 @@ export default function EditUserProfilePage() { const { toast } = useToast() const publicProfileUrl = data.publicProfileUrl - const { copiedToClipboard, copyToClipboard } = useCopyToClipboard() + const { copiedValue, copyToClipboard } = useCopyToClipboard() const validateAutosave = useCallback((values: ProfileAutosaveValues) => { const nextDisplayName = values.displayName.trim() @@ -219,6 +328,24 @@ export default function EditUserProfilePage() { }, ) + useEffect(() => { + if (!actionData || actionData.intent === 'autosave-profile') return + + if (actionData.success) { + toast({ + title: t('saved'), + variant: 'success', + }) + return + } + + toast({ + title: t('something_went_wrong'), + description: actionData.message, + variant: 'destructive', + }) + }, [actionData, toast, t]) + useEffect(() => { setDisplayName(data.profile.displayName) setIsPublic(data.profile.public ?? false) @@ -256,132 +383,298 @@ export default function EditUserProfilePage() { }) } - return ( - - - {t('profile_settings')} - {t('profile_settings_description')} - - - - -
-
-
- - - - - - - - -

{t('if_public')}

-
-
-
-
+ const handleCopySchemaLink = async (url: string) => { + const copied = await copyToClipboard(url) - setDisplayName(event.target.value)} - /> -
+ if (!copied) return + + toast({ + title: t('copied'), + variant: 'success', + }) + } -
-
- - - - - - - - -

- {t('if_activated_public_1')}{' '} - - - {t('if_activated_public_2')} - - - {t('if_activated_public_3')} -

-
-
-
+ return ( +
+ + + {t('profile_settings')} + {t('profile_settings_description')} + + + + +
+
+
+ + + + + + + + +

{t('if_public')}

+
+
+
+
+ + setDisplayName(event.target.value)} + />
- - - {isPublic && ( -
- - -
- event.target.select()} - /> - - -
+
+
+ + + + + + + + +

+ {t('if_activated_public_1')}{' '} + + + {t('if_activated_public_2')} + + + {t('if_activated_public_3')} +

+
+
+
- )} -
-
- -
-
- - - - {getInitials(data.profile?.displayName ?? '')} - - - - - ✎ - + + {isPublic && ( +
+ + +
+ event.target.select()} + /> + + +
+
+ )} +
-
-
- -
+
+
+ + + + {getInitials(data.profile?.displayName ?? '')} + + + + + ✎ + +
+
+ + + + + + + + {t('device_schemas')} + {t('device_schemas_description')} + + + {data.deviceSchemas.length === 0 ? ( +

+ {t('no_device_schemas')} +

+ ) : ( +
+ {data.deviceSchemas.map((schema) => ( +
+
+
+
+

{schema.name}

+ + {t(`schema_visibility_${schema.visibility}`)} + +
+ {schema.description && ( +

+ {schema.description} +

+ )} +
+ +
+ + + + +
+
+ +
+ + +
+ + +
+ +
+ +
+

+ {t('schema_version_history')} +

+ {schema.versions.map((version) => ( +
+
+
+ + v{version.version} + + + {t(`schema_version_status_${version.status}`)} + + + {version.content.sensors.length} {t('sensors')} + + + {version.formatVersion} + +
+

+ {t('schema_hash')}: {version.hash.slice(0, 12)} +

+
+
+ + +
+
+ ))} +
+
+ ))} +
+ )} +
+
+
) } diff --git a/app/services/device-service.server.ts b/app/services/device-service.server.ts index 09e6f45c..43f8517e 100644 --- a/app/services/device-service.server.ts +++ b/app/services/device-service.server.ts @@ -10,6 +10,7 @@ import { } from '~/db/models/device.server' import { verifyLogin } from '~/db/models/user.server' import { type Device, type User } from '~/db/schema' +import { uploadedDeviceSchemaV1 } from '~/lib/device-schemas/device-schema-v1' import { deleteDeviceImage } from '~/lib/s3.server' export const CreateBoxSchema = z.object({ @@ -99,6 +100,8 @@ export const CreateDeviceServiceSchema = z }), ) .optional(), + deviceSchema: uploadedDeviceSchemaV1.optional(), + deviceSchemaVersionId: z.string().optional(), }) .refine((data) => !(data.model && data.sensors && data.model !== 'custom'), { message: 'Model and sensors cannot be specified at the same time.', diff --git a/public/examples/device-schema.json b/public/examples/device-schema.json new file mode 100644 index 00000000..1f9ff96e --- /dev/null +++ b/public/examples/device-schema.json @@ -0,0 +1,35 @@ +{ + "schemaType": "opensensemap.deviceSchema", + "schemaVersion": "1.0.0", + "id": "example-custom-device", + "name": "Example Custom Device", + "version": "1.0.0", + "description": "Example schema for a custom device with temperature, humidity, and particulate matter sensors.", + "tags": ["example", "custom-device"], + "sensors": [ + { + "id": "temperature", + "title": "Temperature", + "unit": "°C", + "sensorType": "HDC1080", + "sensorWikiPhenomenon": "temperature", + "sensorWikiUnit": "Cel" + }, + { + "id": "relative-humidity", + "title": "Relative humidity", + "unit": "%", + "sensorType": "HDC1080", + "sensorWikiPhenomenon": "relative_humidity", + "sensorWikiUnit": "%" + }, + { + "id": "pm10", + "title": "PM10", + "unit": "µg/m³", + "sensorType": "SDS011", + "sensorWikiPhenomenon": "pm10", + "sensorWikiUnit": "ug/m3" + } + ] +} diff --git a/public/locales/de/edit-device-sensors.json b/public/locales/de/edit-device-sensors.json index 6c52e443..a3274e3e 100644 --- a/public/locales/de/edit-device-sensors.json +++ b/public/locales/de/edit-device-sensors.json @@ -1,18 +1,26 @@ { - "sensor_delete_warning": "Die von den Sensoren gemessenen Daten, die du löschen möchtest, werden ebenfalls gelöscht. Wenn du neue Sensoren hinzufügst, vergiss nicht, dein neues Skript abzurufen (siehe Registerkarte „Skript“).", - "copied": "kopiert", - "sensor_id_copied": "Sensor ID in Zwischenablage kopiert", - "copy_failed": "Kopieren fehlgeschlagen", - "copy_failed_desc": "Die Sensor ID wurde nicht kopiert.", - "phenomenon": "Phänomen", - "unit": "Einheit", - "type": "Typ", - "sensor_will_be_deleted": "Dieser Sensor wird gelöscht.", - "undo": "Rückgängig", - "edit": "Editieren", - "delete": "Löschen", - "fill_required_fields": "Bitte alle erforderlichen Felder ausfüllen.", - "cancel": "Abbrechen", - "save": "Speichern", - "add": "Hinzufügen" + "sensor_delete_warning": "Die von den Sensoren gemessenen Daten, die du löschen möchtest, werden ebenfalls gelöscht. Wenn du neue Sensoren hinzufügst, vergiss nicht, dein neues Skript abzurufen (siehe Registerkarte „Skript“).", + "copied": "kopiert", + "sensor_id_copied": "Sensor ID in Zwischenablage kopiert", + "copy_failed": "Kopieren fehlgeschlagen", + "copy_failed_desc": "Die Sensor ID wurde nicht kopiert.", + "phenomenon": "Phänomen", + "unit": "Einheit", + "type": "Typ", + "sensor_will_be_deleted": "Dieser Sensor wird gelöscht.", + "undo": "Rückgängig", + "edit": "Editieren", + "delete": "Löschen", + "fill_required_fields": "Bitte alle erforderlichen Felder ausfüllen.", + "cancel": "Abbrechen", + "save": "Speichern", + "add": "Hinzufügen", + "save_failed": "Sensoren konnten nicht gespeichert werden", + "edit_icon": "Icon bearbeiten", + "schema_detach": "Vom Schema lösen", + "schema_detach_confirm": "Dieses Gerät vom Schema lösen? Danach können Sensornamen, Einheiten und Typen manuell bearbeitet werden.", + "schema_notice_title": "Dieses Gerät verwendet das Schema {{name}} v{{version}}.", + "schema_notice_text": "Phänomen, Einheit und Typ sind gesperrt, damit Messungen vergleichbar bleiben. Du kannst Sensoren weiterhin sortieren und ihre Icons ändern.", + "schema_fields_locked": "Dieser Sensor ist durch das Geräteschema definiert. Phänomen, Einheit und Typ sind gesperrt; hier kann nur das Icon geändert werden.", + "schema_out_of_sync": "Die aktuellen Sensoren passen nicht mehr zu diesem Schema. Löse das Gerät vom Schema, bevor du Sensoren bearbeitest." } diff --git a/public/locales/de/newdevice.json b/public/locales/de/newdevice.json index b0c6eb83..bb09d564 100644 --- a/public/locales/de/newdevice.json +++ b/public/locales/de/newdevice.json @@ -45,9 +45,28 @@ "connection_type": "Verbindungstyp", "select_sensors": "Sensoren auswählen", "sensor_selection": "Sensorauswahl", + "device_schema_tab": "Schema", + "manual_sensors_tab": "Manuell", + "selected_sensors_tab": "Ausgewählt ({{count}})", + "manual_sensors_locked": "Schema-Sensoren sind gesperrt", + "manual_sensors_locked_text": "Entferne das ausgewählte Schema, bevor du Sensoren manuell hinzufügst.", + "no_selected_sensors": "Noch keine Sensoren ausgewählt.", "selectedSensors_one": "{{count}} Sensor ausgewählt", "selectedSensors_other": "{{count}} Sensoren ausgewählt", "add_sensor": "Sensor hinzufügen", + "device_schema_upload": "Geräteschema hochladen", + "device_schema_upload_text": "Importiere ein JSON-Schema, um einen festen Sensorsatz für dieses Gerät zu verwenden.", + "device_schema_registry": "Aus Registry wählen", + "device_schema_registry_text": "Durchsuche öffentliche Schemas und deine eigenen privaten Schemas und verwende eines als festen Sensorsatz.", + "device_schema_registry_search": "Schemas suchen", + "device_schema_registry_empty": "Keine Schemas gefunden.", + "device_schema_registry_error": "Geräteschemas konnten nicht geladen werden.", + "device_schema_registry_selection": "Ausgewähltes Registry-Schema", + "device_schema_use": "Schema verwenden", + "device_schema_mine": "Meins", + "device_schema_locked_sensors_one": "Dieses Schema definiert {{count}} gesperrten Sensor.", + "device_schema_locked_sensors_other": "Dieses Schema definiert {{count}} gesperrte Sensoren.", + "device_schema_invalid_file": "Die ausgewählte Datei ist kein gültiges Geräteschema.", "sensor_selection_info_text": "Wähle die Sensoren für dein Gerät aus, indem du vordefinierte Gruppen oder einzelne Sensoren basierend auf deinem Gerätemodell auswählst. Wenn du ein benutzerdefiniertes Gerät verwendest, konfiguriere die Sensoren manuell.", "select_sensors_text": "Wähle die Sensoren aus die du benutzen willst. Du kannst einen Sensor mehrfach hinzufügen.", "select_sensors_info_text": "Dein Sensor is nicht in der Liste? Füge ihn im <0>sensorWiki hinzu, um ihn auf der openSenseMap zu benutzen.", diff --git a/public/locales/de/profile.json b/public/locales/de/profile.json index 8fcf2aff..c66fbb8c 100644 --- a/public/locales/de/profile.json +++ b/public/locales/de/profile.json @@ -4,6 +4,8 @@ "devices": "Geräte", "sensors": "Sensoren", "measurements": "Messungen", + "device_schemas": "Geräteschemas", + "download": "Herunterladen", "badges": "Badges", "take_over_device": "Gerät übernehmen", "transfer_device": "Gerät übertragen", diff --git a/public/locales/de/settings.json b/public/locales/de/settings.json index dbe7e637..b0fca7c1 100644 --- a/public/locales/de/settings.json +++ b/public/locales/de/settings.json @@ -80,5 +80,21 @@ "unsaved_changes": "Ungespeicherte Änderungen", "saving": "Speichern...", "confirm_email_change": "Änderung der E-Mail Adresse bestätigen", - "confirm_email_change_description": "Bitte gib dein aktuelles Passwort ein." + "confirm_email_change_description": "Bitte gib dein aktuelles Passwort ein.", + "device_schemas": "Geräteschemas", + "device_schemas_description": "Verwalte Schemas, die du beim Erstellen eigener Geräte hochgeladen hast.", + "no_device_schemas": "Du hast noch keine Geräteschemas hochgeladen.", + "schema_visibility_private": "Privat", + "schema_visibility_public": "Öffentlich", + "publish_schema": "Veröffentlichen", + "hide_schema": "Verbergen", + "schema_version_history": "Versionsverlauf", + "publish_new_schema_version": "Neue Version veröffentlichen", + "publish_version": "Version veröffentlichen", + "schema_version_status_published": "Veröffentlicht", + "schema_version_status_deprecated": "Veraltet", + "schema_hash": "Hash", + "download": "Herunterladen", + "copy_link": "Link kopieren", + "sensors": "Sensoren" } diff --git a/public/locales/en/edit-device-sensors.json b/public/locales/en/edit-device-sensors.json index aa366b3d..fc5bebe9 100644 --- a/public/locales/en/edit-device-sensors.json +++ b/public/locales/en/edit-device-sensors.json @@ -1,18 +1,26 @@ { - "copied": "copied", - "sensor_id_copied": "Sensor ID copied to clipboard", - "copy_failed": "Failed to copy", - "copy_failed_desc": "The sensor ID could not be copied.", - "sensor_delete_warning": "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').", - "phenomenon": "Phenomenon", - "unit": "Unit", - "type": "Type", - "sensor_will_be_deleted": "This sensor will be deleted.", - "undo": "Undo", - "edit": "Edit", - "delete": "Delete", - "fill_required_fields": "Please fill out all required fields.", - "cancel": "Cancel", - "save": "Save", - "add": "Add" + "copied": "copied", + "sensor_id_copied": "Sensor ID copied to clipboard", + "copy_failed": "Failed to copy", + "copy_failed_desc": "The sensor ID could not be copied.", + "sensor_delete_warning": "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').", + "phenomenon": "Phenomenon", + "unit": "Unit", + "type": "Type", + "sensor_will_be_deleted": "This sensor will be deleted.", + "undo": "Undo", + "edit": "Edit", + "delete": "Delete", + "fill_required_fields": "Please fill out all required fields.", + "cancel": "Cancel", + "save": "Save", + "add": "Add", + "save_failed": "Could not save sensors", + "edit_icon": "Edit icon", + "schema_detach": "Detach from schema", + "schema_detach_confirm": "Detach this device from its schema? Afterwards sensor names, units, and types can be edited manually.", + "schema_notice_title": "This device uses schema {{name}} v{{version}}.", + "schema_notice_text": "Phenomenon, unit, and type are locked to keep measurements comparable. You can still reorder sensors and change their icons.", + "schema_fields_locked": "This sensor is defined by the device schema. Phenomenon, unit, and type are locked; only the icon can be changed here.", + "schema_out_of_sync": "The current sensors no longer match this schema. Detach the device from the schema before editing sensors." } diff --git a/public/locales/en/newdevice.json b/public/locales/en/newdevice.json index 22214f6c..a90f820d 100644 --- a/public/locales/en/newdevice.json +++ b/public/locales/en/newdevice.json @@ -45,9 +45,28 @@ "connection_type": "Connection type", "select_sensors": "Select sensors", "sensor_selection": "Sensor selection", + "device_schema_tab": "Schema", + "manual_sensors_tab": "Manual", + "selected_sensors_tab": "Selected ({{count}})", + "manual_sensors_locked": "Schema sensors are locked", + "manual_sensors_locked_text": "Clear the selected schema before adding sensors manually.", + "no_selected_sensors": "No sensors selected yet.", "selectedSensors_one": "{{count}} sensor selected", "selectedSensors_other": "{{count}} sensors selected", "add_sensor": "Add Sensor", + "device_schema_upload": "Upload device schema", + "device_schema_upload_text": "Import a JSON schema to use a fixed set of sensors for this device.", + "device_schema_registry": "Choose from registry", + "device_schema_registry_text": "Search public schemas and your own private schemas, then use one as the fixed sensor set.", + "device_schema_registry_search": "Search schemas", + "device_schema_registry_empty": "No schemas found.", + "device_schema_registry_error": "Could not load device schemas.", + "device_schema_registry_selection": "Selected registry schema", + "device_schema_use": "Use schema", + "device_schema_mine": "Mine", + "device_schema_locked_sensors_one": "This schema defines {{count}} locked sensor.", + "device_schema_locked_sensors_other": "This schema defines {{count}} locked sensors.", + "device_schema_invalid_file": "The selected file is not a valid device schema.", "sensor_selection_info_text": "Select sensors for your device by choosing from predefined groups or individual sensors based on your device model. If using a custom device, configure sensors manually.", "select_sensors_text": "Select the sensors you want to use by clicking on the cards. You can add the same sensor multiple times.", "select_sensors_info_text": "Your sensor is not in the list? Add it to the <0>sensorWiki to use it on the openSenseMap.", diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json index 0ba0a128..1d1e1fa8 100644 --- a/public/locales/en/profile.json +++ b/public/locales/en/profile.json @@ -4,6 +4,8 @@ "devices": "Devices", "sensors": "Sensors", "measurements": "Measurements", + "device_schemas": "Device schemas", + "download": "Download", "badges": "Badges", "take_over_device": "Take over device", "transfer_device": "Device Transfer", diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 465a1990..f7b66b7a 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -84,5 +84,21 @@ "unsaved_changes": "Unsaved changes", "saving": "Saving...", "confirm_email_change": "Confirm email change", - "confirm_email_change_description": "Please enter your current password." + "confirm_email_change_description": "Please enter your current password.", + "device_schemas": "Device schemas", + "device_schemas_description": "Manage schemas you uploaded while creating custom devices.", + "no_device_schemas": "You have not uploaded any device schemas yet.", + "schema_visibility_private": "Private", + "schema_visibility_public": "Public", + "publish_schema": "Publish", + "hide_schema": "Hide", + "schema_version_history": "Version history", + "publish_new_schema_version": "Publish a new version", + "publish_version": "Publish version", + "schema_version_status_published": "Published", + "schema_version_status_deprecated": "Deprecated", + "schema_hash": "Hash", + "download": "Download", + "copy_link": "Copy link", + "sensors": "Sensors" }