Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e911ede
feat: install csv-stringify
jona159 Nov 25, 2025
bc70fd2
feat: uncomment route
jona159 Nov 25, 2025
6c2a127
refactor: csv conversion, comma as default delimiter (legacy)
jona159 Nov 26, 2025
948153d
refactor: csv util
jona159 Nov 26, 2025
400c10e
refactor: measurement server for get operations
jona159 Nov 26, 2025
e976f93
refactor: rm get operations from measurement server file
jona159 Nov 26, 2025
57481b8
feat: find matching sensors
jona159 Nov 26, 2025
1e50578
fix: import
jona159 Nov 26, 2025
358fdf7
feat: transform measurements
jona159 Nov 26, 2025
ca102a0
feat: zod schema for boxes data params
jona159 Nov 26, 2025
532a524
feat: boxes data api
jona159 Nov 26, 2025
3d97dde
fix: import
jona159 Nov 26, 2025
e812c3d
feat: boxes data tests
jona159 Nov 26, 2025
b365931
feat: export raw pg client
jona159 Nov 26, 2025
95d85e1
fix: rm unused
jona159 Nov 26, 2025
811960d
feat: include bbox check
jona159 Nov 26, 2025
af6947d
fix: trim text to account for empty last line
jona159 Nov 26, 2025
cccb1b5
fix: throw out csv stringify again
jona159 Dec 10, 2025
005e792
feat: refine parsing to handle grouptag param
jona159 Dec 10, 2025
81fe287
fix: rm unused function
jona159 Dec 10, 2025
ae26525
feat: stream measurements
jona159 Dec 10, 2025
bb8307c
fix: find matching sensors case insensitive phenomenon
jona159 Dec 10, 2025
cacde00
feat: stream in loader
jona159 Dec 10, 2025
317e003
fix: improve tests
jona159 Dec 10, 2025
9e2f7b6
Merge branch 'dev' into feat/boxes-data
jona159 Dec 10, 2025
8552ee7
fix: uninstall csv-stringify, revert getCsv util changes
jona159 Dec 10, 2025
d73647e
feat: use user generation util
jona159 Dec 10, 2025
55f179e
Merge branch 'dev' into feat/boxes-data
jona159 Dec 10, 2025
04f51b9
Merge branch 'dev' into feat/boxes-data
jona159 Dec 10, 2025
3fd8f83
fix: minor things
jona159 Dec 17, 2025
2dad750
fix: rm unuseful comments
jona159 Dec 17, 2025
64d3509
fix: uninstall pg-query-stream
jona159 Dec 17, 2025
3cee4bb
fix: lint warnings
jona159 Dec 17, 2025
2579f67
fix: rm unused var from destructured object
jona159 Dec 17, 2025
bf85203
fix: rm double encoding
jona159 Dec 17, 2025
86c8ed3
fix: use boxid lowercase consistently, rm param normalization
jona159 Dec 17, 2025
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
64 changes: 32 additions & 32 deletions app/db.server.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,45 @@
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import invariant from "tiny-invariant";
import * as schema from "./schema";

let drizzleClient: PostgresJsDatabase<typeof schema>;
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import postgres, { type Sql } from 'postgres'
import invariant from 'tiny-invariant'
import * as schema from './schema'

let drizzleClient: PostgresJsDatabase<typeof schema>
let pg: Sql<any>
declare global {
var __db__: PostgresJsDatabase<typeof schema>;
var __db__:
| {
drizzle: PostgresJsDatabase<typeof schema>
pg: Sql<any>
}
| undefined
}

// this is needed because in development we don't want to restart
// the server with every change, but we want to make sure we don't
// create a new connection to the DB with every change either.
// in production we'll have a single connection to the DB.
if (process.env.NODE_ENV === "production") {
drizzleClient = getClient();
if (process.env.NODE_ENV === 'production') {
const { drizzle, pg: rawPg } = initClient()
drizzleClient = drizzle
pg = rawPg
} else {
if (!global.__db__) {
global.__db__ = getClient();
}
drizzleClient = global.__db__;
if (!global.__db__) {
global.__db__ = initClient()
}
drizzleClient = global.__db__.drizzle
pg = global.__db__.pg
}

function getClient() {
const { DATABASE_URL } = process.env;
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
function initClient() {
const { DATABASE_URL } = process.env
invariant(typeof DATABASE_URL === 'string', 'DATABASE_URL env var not set')

const databaseUrl = new URL(DATABASE_URL);
const databaseUrl = new URL(DATABASE_URL)
console.log(`🔌 setting up drizzle client to ${databaseUrl.host}`)

console.log(`🔌 setting up drizzle client to ${databaseUrl.host}`);
const rawPg = postgres(DATABASE_URL, {
ssl: process.env.PG_CLIENT_SSL === 'true' ? true : false,
})

// NOTE: during development if you change anything in this function, remember
// that this only runs once per server restart and won't automatically be
// re-run per request like everything else is. So if you need to change
// something in this file, you'll need to manually restart the server.
const queryClient = postgres(DATABASE_URL, {
ssl: process.env.PG_CLIENT_SSL === "true" ? true : false,
});
const client = drizzle(queryClient, { schema });
const drizzleDb = drizzle(rawPg, { schema })

return client;
return { drizzle: drizzleDb, pg: rawPg }
}

export { drizzleClient };
export { drizzleClient, pg }
176 changes: 176 additions & 0 deletions app/lib/api-schemas/boxes-data-query-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { z } from 'zod'
import { type DeviceExposureType } from '~/schema'
import { StandardResponse } from '~/utils/response-utils'

export type BoxesDataColumn =
| 'createdAt'
| 'value'
| 'lat'
| 'lon'
| 'height'
| 'boxid'
| 'boxName'
| 'exposure'
| 'sensorId'
| 'phenomenon'
| 'unit'
| 'sensorType'

const BoxesDataQuerySchemaBase = z
.object({
phenomenon: z.string().optional(),

boxid: z
.union([
z.string().transform((s) => s.split(',').map((x) => x.trim())),
z
.array(z.string())
.transform((arr) => arr.map((s) => String(s).trim())),
])
.optional(),
bbox: z
.union([
z.string().transform((s) => s.split(',').map((x) => Number(x.trim()))),
z
.array(z.union([z.string(), z.number()]))
.transform((arr) => arr.map((x) => Number(x))),
])
.refine((arr) => arr.length === 4 && arr.every((n) => !isNaN(n)), {
message: 'bbox must contain exactly 4 numeric coordinates',
})
.optional(),

exposure: z
.union([
z
.string()
.transform((s) =>
s.split(',').map((x) => x.trim() as DeviceExposureType),
),
z
.array(z.string())
.transform((arr) =>
arr.map((s) => String(s).trim() as DeviceExposureType),
),
])
.optional(),

grouptag: z.string().optional(),

fromDate: z
.string()
.transform((s) => new Date(s))
.refine((d) => !isNaN(d.getTime()), {
message: 'from-date is invalid',
})
.optional()
.default(() =>
new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
),
toDate: z
.string()
.transform((s) => new Date(s))
.refine((d) => !isNaN(d.getTime()), {
message: 'to-date is invalid',
})
.optional()
.default(() => new Date().toISOString()),

format: z
.enum(['csv', 'json'], {
errorMap: () => ({ message: "Format must be either 'csv' or 'json'" }),
})
.default('csv'),

// Columns to include
columns: z
.union([
z
.string()
.transform((s) =>
s.split(',').map((x) => x.trim() as BoxesDataColumn),
),
z
.array(z.string())
.transform((arr) =>
arr.map((s) => String(s).trim() as BoxesDataColumn),
),
])
.default([
'sensorId',
'createdAt',
'value',
'lat',
'lon',
] as BoxesDataColumn[]),

download: z
.union([z.string(), z.boolean()])
.transform((v) => {
if (typeof v === 'boolean') return v
return v !== 'false' && v !== '0'
})
.default(true),

delimiter: z.enum(['comma', 'semicolon']).default('comma'),
})
// Validate: must have boxid or bbox, but not both
.superRefine((data, ctx) => {
if (!data.boxid && !data.bbox && !data.grouptag) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'please specify either boxid, bbox or grouptag',
path: ['boxid'],
})
}

if (!data.phenomenon && !data.grouptag) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'phenomenon parameter is required when grouptag is not provided',
path: ['phenomenon'],
})
}
})

export type BoxesDataQueryParams = z.infer<typeof BoxesDataQuerySchemaBase>

/**
* Parse and validate query parameters from request.
* Supports both GET query params and POST JSON body.
*/
export async function parseBoxesDataQuery(
request: Request,
): Promise<BoxesDataQueryParams> {
const url = new URL(request.url)
let params: Record<string, any>
if (request.method !== 'GET') {
const contentType = request.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
try {
params = await request.json()
} catch {
params = Object.fromEntries(url.searchParams)
}
} else {
params = Object.fromEntries(url.searchParams)
}
} else {
params = Object.fromEntries(url.searchParams)
}

const parseResult = BoxesDataQuerySchemaBase.safeParse(params)

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

if (firstError.path.includes('bbox')) {
throw StandardResponse.unprocessableContent(message)
}
throw StandardResponse.badRequest(message)
}

return parseResult.data
}
Loading