Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
35eeb87
feat: add draft for port of user registration to resource route
scheidtdav May 14, 2025
42593f0
feat: partly implement refresh token
scheidtdav May 19, 2025
dcd635f
docs: simplify contributing and add info about api routes and shared …
scheidtdav May 21, 2025
be4ebed
feat(api): finalize user registration endpoint
scheidtdav May 21, 2025
b4b8421
fix(tests): get the tests to run be reconfiguring build steps
scheidtdav May 21, 2025
8fbb075
docs(db): readd db setup and seed scripts with README info for it
scheidtdav May 21, 2025
1de6e69
fix: wrong import of utils
scheidtdav May 21, 2025
08f4405
refactor: remove leftover custom server stuff
scheidtdav May 21, 2025
4a3f8e4
fix(tests): add missing refresh token table
scheidtdav May 21, 2025
29d3034
fix(tests): reenable remaining tests for registration
scheidtdav May 22, 2025
d164738
fix(ci): remove playwright and use correct node version
scheidtdav May 27, 2025
7566724
fix(ci): run the tests with a postgres container
scheidtdav May 27, 2025
44894b4
feat(tests): add coverage report
scheidtdav May 27, 2025
77b4cc9
fix(build): reorganize server modules to correctly split client/ server
scheidtdav May 27, 2025
5612a5f
fix(build): miss an import
scheidtdav May 27, 2025
2e335d5
fix(build): remove leftovers from custom server implementation
scheidtdav May 27, 2025
bbf5430
chore(deps): bump react-router dependencies
scheidtdav May 27, 2025
bf19c7e
chore(deps): update react-router
scheidtdav May 27, 2025
7d91045
feat/user me api (#559)
scheidtdav May 27, 2025
a5699de
feat(api): add root route (#560)
scheidtdav May 27, 2025
760914b
start
JerryVincent Jun 13, 2025
81a1f9c
new commit
JerryVincent Jun 13, 2025
acf1770
tested docs
JerryVincent Jun 16, 2025
eedb806
added a route
JerryVincent Jun 16, 2025
a6245ea
Added API Docs
JerryVincent Jun 16, 2025
4712c6a
modified
JerryVincent Jun 16, 2025
f63dc07
removed unsupported packages
JerryVincent Jun 16, 2025
7741945
updated
JerryVincent Jun 16, 2025
3b620a0
Modified
JerryVincent Jun 17, 2025
f51f518
script generation without using ts-node.
JerryVincent Jun 17, 2025
5f7a6ef
modified
JerryVincent Jun 17, 2025
f9ecca4
Merge branch 'api-prod' into feat/user-registration-api
scheidtdav Jun 18, 2025
bd0e52e
fix: update package-lock.json
scheidtdav Jun 18, 2025
31f793c
Updated (#575)
JerryVincent Jun 24, 2025
511b94a
Removed duplicate Documentation section (#576)
JerryVincent Jun 25, 2025
98617c3
Update README.md
JerryVincent Jun 25, 2025
9492e13
Feat/api email and password (#561)
scheidtdav Jun 25, 2025
8a721f7
feat/api auth (#562)
scheidtdav Jun 25, 2025
ef552e1
feat(api): boxes for user endpoints (#573)
scheidtdav Jun 25, 2025
46e89cd
feat/api misc (#571)
scheidtdav Jun 25, 2025
fcf4b8d
Merge branch 'dev' into feat/user-registration-api
jona159 Jun 25, 2025
1bd97eb
feat(api): add route and test files
scheidtdav Jun 18, 2025
46b0422
feat: add test code
scheidtdav Jun 18, 2025
787b662
feat: add dummy sensors to devices and implement getting them back
scheidtdav Jun 25, 2025
95acb53
Merge branch 'dev' into feat/api-boxes-sensors
scheidtdav Jul 2, 2025
eb84619
feat: prefer dev server in no production envs and hide dev in prod
scheidtdav Jul 2, 2025
84f57ca
feat(docs): start adding docs to route
scheidtdav Jul 2, 2025
a232d6b
Merge branch 'dev' into feat/api-boxes-sensors
scheidtdav Jul 23, 2025
cf36b0a
feat: finish up to the point where we need measurements
scheidtdav Jul 23, 2025
0a35d3b
fix: api routes without need for measurements
scheidtdav Jul 30, 2025
89e8c04
fix: stats call
scheidtdav Jul 30, 2025
9a8e679
fix: remaining tests
scheidtdav Jul 30, 2025
32bdb9e
fix: frontend issue from changing the service implementation
scheidtdav Jul 30, 2025
bba0bf5
feat: use a redirect for a more consistent route path
scheidtdav Aug 13, 2025
3a14e54
feat: add deprecation notices to api info
scheidtdav Aug 13, 2025
153e83a
docs: add deprecation notice for user registration api
scheidtdav Aug 13, 2025
a50bc7c
Merge branch 'dev' into feat/api-delete-sensor-data
scheidtdav Nov 19, 2025
6a057f1
Merge branch 'dev' into feat/api-delete-sensor-data
scheidtdav Dec 17, 2025
350364e
fix: remove invalid parameter
scheidtdav Dec 17, 2025
abf6a28
feat: add rudimentary delete implementation
scheidtdav Dec 17, 2025
b632e35
Merge branch 'dev' into feat/api-delete-sensor-data
scheidtdav Jan 12, 2026
cda8787
fix: remove new route
scheidtdav Jan 12, 2026
42a4fea
fix: remove permanent redirect
scheidtdav Jan 12, 2026
7ef062a
feat: add missing parameters for deletion
scheidtdav Jan 12, 2026
b82f392
feat: add tests
scheidtdav Jan 12, 2026
29ac73c
fix: tests
scheidtdav Jan 12, 2026
095bd6d
fix: coerce values to properly parse params
scheidtdav Jan 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion app/models/measurement.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, desc, eq, gte, lte, sql } from 'drizzle-orm'
import { and, desc, eq, gt, gte, inArray, lt, lte, sql } from 'drizzle-orm'
import { drizzleClient } from '~/db.server'
import {
type LastMeasurement,
Expand Down Expand Up @@ -256,6 +256,32 @@ export async function deleteMeasurementsForSensor(sensorId: string) {
.where(eq(measurement.sensorId, sensorId))
}

export async function deleteSensorMeasurementsForTimes(
sensorId: string,
dates: Date[],
) {
return await drizzleClient
.delete(measurement)
.where(
and(eq(measurement.sensorId, sensorId), inArray(measurement.time, dates)),
)
}

export async function deleteSensorMeasurementsForTimeRange(
sensorId: string,
greaterThan: Date,
lessThan: Date,
) {
return await drizzleClient
.delete(measurement)
.where(
and(
eq(measurement.sensorId, sensorId),
and(gt(measurement.time, greaterThan), lt(measurement.time, lessThan)),
),
)
}

export async function deleteMeasurementsForTime(date: Date) {
return await drizzleClient
.delete(measurement)
Expand Down
167 changes: 167 additions & 0 deletions app/routes/api.boxes.$deviceId.$sensorId.measurements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { type ActionFunctionArgs } from 'react-router'
import z from 'zod'
import { getUserFromJwt } from '~/lib/jwt'
import { getUserDevices } from '~/models/device.server'
import {
deleteMeasurementsForSensor,
deleteSensorMeasurementsForTimeRange,
deleteSensorMeasurementsForTimes,
} from '~/models/measurement.server'
import { StandardResponse } from '~/utils/response-utils'

export async function action({ request, params }: ActionFunctionArgs) {
try {
const { deviceId, sensorId } = params
if (!deviceId || !sensorId)
return StandardResponse.badRequest(
'Invalid device id or sensor id specified',
)

const jwtResponse = await getUserFromJwt(request)

if (typeof jwtResponse === 'string')
return StandardResponse.forbidden(
'Invalid JWT authorization. Please sign in to obtain new JWT.',
)

if (request.method !== 'DELETE')
return StandardResponse.methodNotAllowed('Endpoint only supports DELETE')

const userDevices = await getUserDevices(jwtResponse.id)
if (!userDevices.some((d) => d.id === deviceId))
return StandardResponse.forbidden(
'You are not allowed to delete data of the given device',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlikely to break anything, but the message in the old api was User does not own this senseBox (mentioning this because in the old app there was also a test for this: ```expect(response).to.have.json({ code: 'Forbidden', message: 'User does not own this senseBox' });

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes good point, I'm thinking we should still changes this, because we should get rid of the strict "senseBox" branding and use the more generic "device" term. As you said its unlikely to break anything, so I guess we can keep the message?

)

const device = userDevices.find((d) => d.id === deviceId)
if (!device?.sensors.some((s) => s.id === sensorId))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the old app would have thrown a 404 here (see Box.js l. 387)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried with the existing api and it indeed returns 404. Thanks for pointing out!

return StandardResponse.forbidden(
'You are not allowed to delete data of the given sensor',
)

try {
const parsedParams = await parseQueryParams(request)
let count = 0

if (parsedParams.deleteAllMeasurements)
count = (await deleteMeasurementsForSensor(sensorId)).count
else if (parsedParams.timestamps)
count = (
await deleteSensorMeasurementsForTimes(
sensorId,
parsedParams.timestamps,
)
).count
else if (parsedParams['from-date'] && parsedParams['to-date'])
count = (
await deleteSensorMeasurementsForTimeRange(
sensorId,
parsedParams['from-date'],
parsedParams['to-date'],
)
).count

return StandardResponse.ok({
message: `Successfully deleted ${count} of sensor ${sensorId}`,
})
} catch (e) {
if (e instanceof Response) return e
else throw e
}
} catch (err: any) {
return StandardResponse.internalServerError(
err.message || 'An unexpected error occured',
)
}
}

const DeleteQueryParams = z
.object({
'from-date': z
.string()
.transform((s) => new Date(s))
.refine((d) => !isNaN(d.getTime()), {
message: 'from-date is invalid',
})
.optional(),
'to-date': z
.string()
.transform((s) => new Date(s))
.refine((d) => !isNaN(d.getTime()), {
message: 'to-date is invalid',
})
.optional(),
timestamps: z
.preprocess((val) => {
if (Array.isArray(val)) return val
else return [val]
}, z.array(z.string()))
.transform((a) => a.map((i) => new Date(i)))
.refine((a) => a.some((i) => !isNaN(i.getTime())), {
message: 'timestamps contains invalid input',
})
.optional(),
deleteAllMeasurements: z.coerce.boolean().optional(),
})
.superRefine((data, ctx) => {
const fromDateSet = data['from-date'] !== undefined
const toDateSet = data['to-date'] !== undefined
const timestampsSet = data.timestamps !== undefined
const deleteAllSet = data.deleteAllMeasurements !== undefined

if (deleteAllSet && (timestampsSet || fromDateSet || toDateSet)) {
const paths: string[] = []
if (timestampsSet) paths.push('timestamps')
if (fromDateSet) paths.push('from-date')
if (toDateSet) paths.push('to-date')
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Parameter deleteAllMeasurements can only be used by itself',
path: paths,
})
} else if (!deleteAllSet && timestampsSet && fromDateSet && toDateSet)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Please specify only timestamps or a range with from-date and to-date',
path: ['timestamps', 'from-date', 'to-date'],
})
else if (!deleteAllSet && !timestampsSet && !fromDateSet && !toDateSet)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Please specify only timestamps or a range with from-date and to-date',
path: ['timestamps', 'from-date', 'to-date'],
})
})

const parseQueryParams = async (
request: Request,
): Promise<z.infer<typeof DeleteQueryParams>> => {
const url = new URL(request.url)
let params: Record<string, any>
if (request.method !== 'GET') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ì think this endpoint should only support the delete method?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember why its there and you are perfectly correct.. removing it!

const contentType = request.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
try {
params = await request.json()
} catch {
params = Object.fromEntries(url.searchParams)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in principle it is a good idea to fall back to params, but i am a bit unsure if we should do this here with regards to backwards-compatibility. In the old docs the endpoint is described as using the request body, so i guess this would introduce a new behaviour? Also if the content type is invalid (so != application/json) the old app would have thrown a 415 if i see it correctly

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying it with the existing API, even though the docs specify it to work with body parameters, I actually does not seem to work with those at all.
Thus I better remove the request.json() part, right?

(funnily enough, you have to set content-type: application/json for it to work even though you never send json data. Definitely an inconsistency. The question is if we should loosen that or keep it for now?)

}
} else {
params = Object.fromEntries(url.searchParams)
}
} else {
params = Object.fromEntries(url.searchParams)
}

const parseResult = DeleteQueryParams.safeParse(params)

if (!parseResult.success) {
const firstError = parseResult.error.errors[0]
const message = firstError.message || 'Invalid query parameters'
throw StandardResponse.badRequest(message)
}

return parseResult.data
}
63 changes: 35 additions & 28 deletions app/routes/api.boxes.$deviceId.sensors.$sensorId.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,44 @@
import { type LoaderFunction, type LoaderFunctionArgs } from "react-router";
import { getLatestMeasurementsForSensor } from "~/lib/measurement-service.server";
import { StandardResponse } from "~/utils/response-utils";
import { type LoaderFunction, type LoaderFunctionArgs } from 'react-router'
import { getLatestMeasurementsForSensor } from '~/lib/measurement-service.server'
import { StandardResponse } from '~/utils/response-utils'

export const loader: LoaderFunction = async ({
request,
params,
request,
params,
}: LoaderFunctionArgs): Promise<Response> => {
try {
const deviceId = params.deviceId;
if (deviceId === undefined)
return StandardResponse.badRequest("Invalid device id specified");
try {
const deviceId = params.deviceId
if (deviceId === undefined)
return StandardResponse.badRequest('Invalid device id specified')

const sensorId = params.sensorId;
if (sensorId === undefined)
return StandardResponse.badRequest("Invalid sensor id specified");
const sensorId = params.sensorId
if (sensorId === undefined)
return StandardResponse.badRequest('Invalid sensor id specified')

const searchParams = new URL(request.url).searchParams;
const onlyValue =
(searchParams.get("onlyValue")?.toLowerCase() ?? "") === "true";
if (sensorId === undefined && onlyValue)
return StandardResponse.badRequest("onlyValue can only be used when a sensor id is specified");
const searchParams = new URL(request.url).searchParams
const onlyValue =
(searchParams.get('onlyValue')?.toLowerCase() ?? '') === 'true'
if (sensorId === undefined && onlyValue)
return StandardResponse.badRequest(
'onlyValue can only be used when a sensor id is specified',
)

const meas = await getLatestMeasurementsForSensor(deviceId, sensorId, undefined);
const meas = await getLatestMeasurementsForSensor(
deviceId,
sensorId,
undefined,
)

if (meas == null)
return StandardResponse.notFound("Device not found.");
if (meas == null) return StandardResponse.notFound('Device not found.')

if (onlyValue)
return StandardResponse.ok(meas["lastMeasurement"]?.value ?? null);
if (onlyValue)
return StandardResponse.ok(meas['lastMeasurement']?.value ?? null)

return StandardResponse.ok({ ...meas, _id: meas.id } /* for legacy purposes */);
} catch (err) {
console.warn(err);
return StandardResponse.internalServerError();
}
};
return StandardResponse.ok(
{ ...meas, _id: meas.id } /* for legacy purposes */,
)
} catch (err) {
console.warn(err)
return StandardResponse.internalServerError()
}
}
46 changes: 24 additions & 22 deletions app/routes/api.boxes.$deviceId.sensors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type LoaderFunction, type LoaderFunctionArgs } from "react-router";
import { getLatestMeasurements } from "~/lib/measurement-service.server";
import { StandardResponse } from "~/utils/response-utils";
import { type LoaderFunction, type LoaderFunctionArgs } from 'react-router'
import { getLatestMeasurements } from '~/lib/measurement-service.server'
import { StandardResponse } from '~/utils/response-utils'

/**
* @openapi
Expand Down Expand Up @@ -31,28 +31,30 @@ import { StandardResponse } from "~/utils/response-utils";
*/

export const loader: LoaderFunction = async ({
request,
params,
request,
params,
}: LoaderFunctionArgs): Promise<Response> => {
try {
const deviceId = params.deviceId;
if (deviceId === undefined)
return StandardResponse.badRequest("Invalid device id specified");
try {
const deviceId = params.deviceId
if (deviceId === undefined)
return StandardResponse.badRequest('Invalid device id specified')

const url = new URL(request.url);
const countParam = url.searchParams.get("count");
const url = new URL(request.url)
const countParam = url.searchParams.get('count')

let count: undefined | number = undefined;
if (countParam !== null && Number.isNaN(countParam))
return StandardResponse.badRequest("Illegal value for parameter count. allowed values: numbers");
let count: undefined | number = undefined
if (countParam !== null && Number.isNaN(countParam))
return StandardResponse.badRequest(
'Illegal value for parameter count. allowed values: numbers',
)

count = countParam === null ? undefined : Number(countParam);
count = countParam === null ? undefined : Number(countParam)

const meas = await getLatestMeasurements(deviceId, count);
const meas = await getLatestMeasurements(deviceId, count)

return StandardResponse.ok(meas);
} catch (err) {
console.warn(err);
return StandardResponse.internalServerError();
}
};
return StandardResponse.ok(meas)
} catch (err) {
console.warn(err)
return StandardResponse.internalServerError()
}
}
20 changes: 13 additions & 7 deletions app/routes/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { type LoaderFunctionArgs } from 'react-router'

type RouteInfo = { path: string; method: 'GET' | 'PUT' | 'POST' | 'DELETE' }
type RouteInfo = {
path: string
method: 'GET' | 'PUT' | 'POST' | 'DELETE'
deprecationNotice?: string
}

const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = {
noauth: [
Expand Down Expand Up @@ -34,7 +38,6 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = {
path: `boxes/data`,
method: 'GET',
},

// {
// path: `boxes/:boxId`,
// method: "GET",
Expand Down Expand Up @@ -145,10 +148,10 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = {
path: `boxes/:boxId`,
method: 'DELETE',
},
// {
// path: `boxes/:boxId/:sensorId/measurements`,
// method: "DELETE",
// },
{
path: `boxes/:boxId/:sensorId/measurements`,
method: 'DELETE',
},
{
path: `users/sign-out`,
method: 'POST',
Expand Down Expand Up @@ -213,7 +216,10 @@ export async function loader({}: LoaderFunctionArgs) {

lines.push('\nRoutes requiring valid authentication through JWT:')

for (const r of routes.auth) lines.push(`${r.method}\t${r.path}`)
for (const r of routes.auth)
lines.push(
`${r.method}\t${r.path}\t${r.deprecationNotice ? 'DEPRECATED: ' + r.deprecationNotice : ''}`,
)

return new Response(lines.join('\n'), {
status: 200,
Expand Down
Loading
Loading