diff --git a/app/models/measurement.server.ts b/app/models/measurement.server.ts index 26c2eb7c..b65e2866 100644 --- a/app/models/measurement.server.ts +++ b/app/models/measurement.server.ts @@ -9,6 +9,7 @@ import { measurements1hourView, measurements1monthView, measurements1yearView, + sensor, } from '~/schema' import { type MinimalDevice, @@ -287,3 +288,14 @@ export async function deleteMeasurementsForTime(date: Date) { .delete(measurement) .where(eq(measurement.time, date)) } + +export async function deleteMeasurementsForDevice(deviceId: string) { + const sensorIds = drizzleClient + .select({ id: sensor.id }) + .from(sensor) + .where(eq(sensor.deviceId, deviceId)) + + return await drizzleClient + .delete(measurement) + .where(inArray(measurement.sensorId, sensorIds)) +} diff --git a/app/routes/device.$deviceId.edit.delete-measurements.tsx b/app/routes/device.$deviceId.edit.delete-measurements.tsx new file mode 100644 index 00000000..681237ec --- /dev/null +++ b/app/routes/device.$deviceId.edit.delete-measurements.tsx @@ -0,0 +1,151 @@ +import * as React from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + Form, + data, + redirect, + useActionData, + useLoaderData, +} from 'react-router' +import invariant from 'tiny-invariant' +import { Button } from '~/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '~/components/ui/card' +import { Input } from '~/components/ui/input' +import { Label } from '~/components/ui/label' +import { getUserDevice } from '~/models/device.server' +import { deleteMeasurementsForDevice } from '~/models/measurement.server' +import { verifyLogin } from '~/models/user.server' +import { getUserEmail, getUserId } from '~/utils/session.server' + +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await getUserId(request) + if (!userId) return redirect('/') + + const deviceId = params.deviceId + invariant(typeof deviceId === 'string', 'Device id not found.') + + const device = await getUserDevice({ id: deviceId, userId: userId }) + if (!device) return redirect('/profile/me') + + return data({ device }) +} + +export async function action({ request, params }: ActionFunctionArgs) { + const userId = await getUserId(request) + if (!userId) return redirect('/') + + const deviceId = params.deviceId + invariant(typeof deviceId === 'string', 'Device id not found.') + + const device = await getUserDevice({ id: deviceId, userId: userId }) + if (!device) return redirect('/profile/me') + + const formData = await request.formData() + const passwordConfirm = formData.get('passwordConfirm') + invariant(typeof passwordConfirm === 'string', 'password must be a string') + + const userEmail = await getUserEmail(request) + invariant(typeof userEmail === 'string', 'email not found') + + const user = await verifyLogin(userEmail, passwordConfirm) + if (!user) { + return data( + { + success: false, + noMeasurements: false, + errors: { passwordConfirm: 'Invalid password' } as { passwordConfirm: string } | null, + }, + { status: 400 }, + ) + } + + const result = await deleteMeasurementsForDevice(deviceId) + + return data({ + success: true, + noMeasurements: result.count === 0, + errors: null as { passwordConfirm: string } | null, + }) +} + +export default function DeleteMeasurementsPage() { + const { device } = useLoaderData() + const actionData = useActionData() + const passwordRef = React.useRef(null) + const [password, setPassword] = React.useState('') + + const { t } = useTranslation('delete-measurements') + + React.useEffect(() => { + if (actionData?.errors?.passwordConfirm) { + passwordRef.current?.focus() + } + if (actionData?.success) { + setPassword('') + } + }, [actionData]) + + return ( +
+ + + + {t('delete_measurements')} + + + }} + /> + + + + + {actionData?.success && !actionData.noMeasurements && ( +
+ {t('delete_success')} +
+ )} + + {actionData?.success && actionData.noMeasurements && ( +
+ {t('no_measurements')} +
+ )} + +
+ + setPassword(e.target.value)} + required + /> + {actionData?.errors?.passwordConfirm && ( +
+ {actionData.errors.passwordConfirm} +
+ )} +
+ + +
+
+
+ ) +} diff --git a/app/routes/device.$deviceId.edit.tsx b/app/routes/device.$deviceId.edit.tsx index 12c81962..ddaa5553 100644 --- a/app/routes/device.$deviceId.edit.tsx +++ b/app/routes/device.$deviceId.edit.tsx @@ -10,6 +10,7 @@ import { Cpu, ArrowLeft, NotepadText, + Trash2, } from 'lucide-react' import { useState } from 'react' import { @@ -90,6 +91,11 @@ export default function EditBox() { href: `/device/${deviceId}/edit/transfer`, icon: ArrowRightLeft, }, + { + title: 'Delete Measurements', + href: `/device/${deviceId}/edit/delete-measurements`, + icon: Trash2, + }, ] return ( diff --git a/public/locales/de/delete-measurements.json b/public/locales/de/delete-measurements.json new file mode 100644 index 00000000..3e68ef0d --- /dev/null +++ b/public/locales/de/delete-measurements.json @@ -0,0 +1,7 @@ +{ + "delete_measurements": "Alle Messungen löschen", + "confirm_permanent_deletion": "Dadurch werden alle Messungen für alle Sensoren von {{device}} dauerhaft gelöscht. Das Gerät und seine Sensoren bleiben erhalten. Bitte bestätigen Sie mit Ihrem Passwort.", + "password": "Passwort", + "delete_success": "Alle Messungen wurden erfolgreich gelöscht.", + "no_measurements": "Dieses Gerät hat keine Messungen zum Löschen." +} diff --git a/public/locales/en/delete-measurements.json b/public/locales/en/delete-measurements.json new file mode 100644 index 00000000..7a398e47 --- /dev/null +++ b/public/locales/en/delete-measurements.json @@ -0,0 +1,7 @@ +{ + "delete_measurements": "Delete all measurements", + "confirm_permanent_deletion": "This will permanently delete all measurements for all sensors of {{device}}. The device and its sensors will remain. Please confirm with your password.", + "password": "Password", + "delete_success": "All measurements have been successfully deleted.", + "no_measurements": "This device has no measurements to delete." +}