From 0de74b20f1462c49961ebccb5fcd778d21b24b4d Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Wed, 25 Sep 2024 15:15:43 +0200 Subject: [PATCH 01/34] start migration to drizzle and postgres --- docker-compose.yml | 24 +- packages/api/app.js | 19 +- .../api/lib/controllers/usersController.js | 13 +- packages/api/lib/helpers/jwtHelpers.js | 20 +- .../0-mongoshell-boxes-integrations.js | 0 ...-mongoshell-users-name-createdAt-apikey.js | 0 .../2-node-make-users-unique.js | 0 .../10-node-update-grouptag.js | 0 .../3-mongoshell-normalize-boxes.js | 0 .../4-mongoshell-refactor-boxlocation.js | 0 .../5-mongoshell-boxes-add-field.js | 0 .../6-node-set-latest-measurement.js | 0 .../7-node-boxes-add-field-access-token.js | 0 .../8-node-boxes-fix-sensors.js | 0 .../9-node-boxes-add-field-useAuth.js | 0 .../{migrations => _migrations}/README.md | 0 packages/models/drizzle.config.js | 37 ++ packages/models/index.js | 25 +- packages/models/package.json | 10 + packages/models/schema/enum.js | 26 + packages/models/schema/measurement.js | 75 +++ packages/models/schema/schema.js | 161 +++++++ packages/models/schema/types.js | 13 + packages/models/src/db.js | 128 ++--- packages/models/src/drizzle.js | 40 ++ packages/models/src/profile/profile.js | 19 + packages/models/src/user/user.js | 58 ++- yarn.lock | 444 +++++++++++++++++- 28 files changed, 1005 insertions(+), 107 deletions(-) rename packages/models/{migrations => _migrations}/0-3_usermanagement/0-mongoshell-boxes-integrations.js (100%) rename packages/models/{migrations => _migrations}/0-3_usermanagement/1-mongoshell-users-name-createdAt-apikey.js (100%) rename packages/models/{migrations => _migrations}/0-3_usermanagement/2-node-make-users-unique.js (100%) rename packages/models/{migrations => _migrations}/10-11_box_grouptag/10-node-update-grouptag.js (100%) rename packages/models/{migrations => _migrations}/3-4_normalize_boxes/3-mongoshell-normalize-boxes.js (100%) rename packages/models/{migrations => _migrations}/4-5_mobile_boxes/4-mongoshell-refactor-boxlocation.js (100%) rename packages/models/{migrations => _migrations}/5-7_measurements_boxes/5-mongoshell-boxes-add-field.js (100%) rename packages/models/{migrations => _migrations}/5-7_measurements_boxes/6-node-set-latest-measurement.js (100%) rename packages/models/{migrations => _migrations}/7-9_box_access_token/7-node-boxes-add-field-access-token.js (100%) rename packages/models/{migrations => _migrations}/7-9_box_access_token/8-node-boxes-fix-sensors.js (100%) rename packages/models/{migrations => _migrations}/7-9_box_access_token/9-node-boxes-add-field-useAuth.js (100%) rename packages/models/{migrations => _migrations}/README.md (100%) create mode 100644 packages/models/drizzle.config.js create mode 100644 packages/models/schema/enum.js create mode 100644 packages/models/schema/measurement.js create mode 100644 packages/models/schema/schema.js create mode 100644 packages/models/schema/types.js create mode 100644 packages/models/src/drizzle.js create mode 100644 packages/models/src/profile/profile.js diff --git a/docker-compose.yml b/docker-compose.yml index fcba270a..eae50102 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,12 @@ -version: "3.9" - -volumes: - mongo-data: - services: db: - image: mongo:5 - container_name: osem-dev-mongo + image: timescale/timescaledb-ha:pg15-latest + command: + - -cshared_preload_libraries=timescaledb,pg_cron + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=opensensemap ports: - - "27017:27017" - volumes: - - mongo-data:/data/db - # - ./dumps/boxes:/exports/boxes - # - ./dumps/measurements:/exports/measurements - - ./.scripts/mongodb/osem_admin.sh:/docker-entrypoint-initdb.d/osem_admin.sh - # - ./.scripts/mongodb/osem_seed_boxes.sh:/docker-entrypoint-initdb.d/osem_seed_boxes.sh - # - ./.scripts/mongodb/osem_seed_measurements.sh:/docker-entrypoint-initdb.d/osem_seed_measurements.sh + - 5432:5432 \ No newline at end of file diff --git a/packages/api/app.js b/packages/api/app.js index 762e5580..8e517592 100644 --- a/packages/api/app.js +++ b/packages/api/app.js @@ -53,9 +53,11 @@ if (config.get('logLevel') === 'debug') { server.use(debugLogger); } -db.connect() - .then(function () { - // attach Routes +const run = async function () { + try { + // TODO: Get a client from the Pool and test connection + await db.connect(); + routes(server); // start the server @@ -63,11 +65,14 @@ db.connect() stdLogger.logger.info(`${server.name} listening at ${server.url}`); postToMattermost(`openSenseMap API started. Version: ${getVersion}`); }); - }) - .catch(function (err) { - stdLogger.logger.fatal(err, 'Couldn\'t connect to MongoDB. Exiting...'); + } catch (error) { + stdLogger.logger.fatal(error, 'Couldn\'t connect to PostgreSQL. Exiting...'); process.exit(1); - }); + } +}; + +// 🔥 Fire up API +run(); // InternalServerError is the only error we want to report to Honeybadger.. server.on('InternalServer', function (req, res, err, callback) { diff --git a/packages/api/lib/controllers/usersController.js b/packages/api/lib/controllers/usersController.js index 4b8d77e1..834c4a52 100644 --- a/packages/api/lib/controllers/usersController.js +++ b/packages/api/lib/controllers/usersController.js @@ -15,6 +15,7 @@ const { User } = require('@sensebox/opensensemap-api-models'), refreshJwt, invalidateToken, } = require('../helpers/jwtHelpers'); +const { createUser, findUserByNameOrEmail, checkPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); /** * define for nested user parameter for box creation request @@ -53,10 +54,11 @@ const { User } = require('@sensebox/opensensemap-api-models'), * @apiSuccess (Created 201) {Object} data `{ "user": {"name":"fullname","email":"test@test.de","role":"user","language":"en_US","boxes":[],"emailIsConfirmed":false} }` */ const registerUser = async function registerUser (req, res) { - const { email, password, language, name, integrations } = req._userParams; + const { email, password, language, name } = req._userParams; try { - const newUser = await new User({ name, email, password, language, integrations }).save(); + const newUser = await createUser(name, email, password, language); + postToMattermost( `New User: ${newUser.name} (${redactEmail(newUser.email)})` ); @@ -101,10 +103,7 @@ const signIn = async function signIn (req, res) { const { email: emailOrName, password } = req._userParams; try { - // lowercase for email - const user = await User.findOne({ - $or: [{ email: emailOrName.toLowerCase() }, { name: emailOrName }], - }).exec(); + const user = await findUserByNameOrEmail(emailOrName); if (!user) { return Promise.reject( @@ -112,7 +111,7 @@ const signIn = async function signIn (req, res) { ); } - if (await user.checkPassword(password)) { + if (await checkPassword(password, user.password)) { const { token, refreshToken } = await createToken(user); res.send(200, { diff --git a/packages/api/lib/helpers/jwtHelpers.js b/packages/api/lib/helpers/jwtHelpers.js index 382c28cc..132989cb 100644 --- a/packages/api/lib/helpers/jwtHelpers.js +++ b/packages/api/lib/helpers/jwtHelpers.js @@ -39,14 +39,18 @@ const createToken = function createToken (user) { // it is a HMAC of the jwt string const refreshToken = hashJWT(token); try { - await user.update({ - $set: { - refreshToken, - refreshTokenExpires: moment.utc() - .add(Number(refresh_token_validity_ms), 'ms') - .toDate() - } - }).exec(); + // TODO: do we need a new table for tokens??? + user.refreshToken = refreshToken; + user.refreshTokenExpires = moment.utc().add(Number(refresh_token_validity_ms), 'ms') + .toDate(); + // await user.update({ + // $set: { + // refreshToken, + // refreshTokenExpires: moment.utc() + // .add(Number(refresh_token_validity_ms), 'ms') + // .toDate() + // } + // }).exec(); return resolve({ token, refreshToken }); } catch (err) { diff --git a/packages/models/migrations/0-3_usermanagement/0-mongoshell-boxes-integrations.js b/packages/models/_migrations/0-3_usermanagement/0-mongoshell-boxes-integrations.js similarity index 100% rename from packages/models/migrations/0-3_usermanagement/0-mongoshell-boxes-integrations.js rename to packages/models/_migrations/0-3_usermanagement/0-mongoshell-boxes-integrations.js diff --git a/packages/models/migrations/0-3_usermanagement/1-mongoshell-users-name-createdAt-apikey.js b/packages/models/_migrations/0-3_usermanagement/1-mongoshell-users-name-createdAt-apikey.js similarity index 100% rename from packages/models/migrations/0-3_usermanagement/1-mongoshell-users-name-createdAt-apikey.js rename to packages/models/_migrations/0-3_usermanagement/1-mongoshell-users-name-createdAt-apikey.js diff --git a/packages/models/migrations/0-3_usermanagement/2-node-make-users-unique.js b/packages/models/_migrations/0-3_usermanagement/2-node-make-users-unique.js similarity index 100% rename from packages/models/migrations/0-3_usermanagement/2-node-make-users-unique.js rename to packages/models/_migrations/0-3_usermanagement/2-node-make-users-unique.js diff --git a/packages/models/migrations/10-11_box_grouptag/10-node-update-grouptag.js b/packages/models/_migrations/10-11_box_grouptag/10-node-update-grouptag.js similarity index 100% rename from packages/models/migrations/10-11_box_grouptag/10-node-update-grouptag.js rename to packages/models/_migrations/10-11_box_grouptag/10-node-update-grouptag.js diff --git a/packages/models/migrations/3-4_normalize_boxes/3-mongoshell-normalize-boxes.js b/packages/models/_migrations/3-4_normalize_boxes/3-mongoshell-normalize-boxes.js similarity index 100% rename from packages/models/migrations/3-4_normalize_boxes/3-mongoshell-normalize-boxes.js rename to packages/models/_migrations/3-4_normalize_boxes/3-mongoshell-normalize-boxes.js diff --git a/packages/models/migrations/4-5_mobile_boxes/4-mongoshell-refactor-boxlocation.js b/packages/models/_migrations/4-5_mobile_boxes/4-mongoshell-refactor-boxlocation.js similarity index 100% rename from packages/models/migrations/4-5_mobile_boxes/4-mongoshell-refactor-boxlocation.js rename to packages/models/_migrations/4-5_mobile_boxes/4-mongoshell-refactor-boxlocation.js diff --git a/packages/models/migrations/5-7_measurements_boxes/5-mongoshell-boxes-add-field.js b/packages/models/_migrations/5-7_measurements_boxes/5-mongoshell-boxes-add-field.js similarity index 100% rename from packages/models/migrations/5-7_measurements_boxes/5-mongoshell-boxes-add-field.js rename to packages/models/_migrations/5-7_measurements_boxes/5-mongoshell-boxes-add-field.js diff --git a/packages/models/migrations/5-7_measurements_boxes/6-node-set-latest-measurement.js b/packages/models/_migrations/5-7_measurements_boxes/6-node-set-latest-measurement.js similarity index 100% rename from packages/models/migrations/5-7_measurements_boxes/6-node-set-latest-measurement.js rename to packages/models/_migrations/5-7_measurements_boxes/6-node-set-latest-measurement.js diff --git a/packages/models/migrations/7-9_box_access_token/7-node-boxes-add-field-access-token.js b/packages/models/_migrations/7-9_box_access_token/7-node-boxes-add-field-access-token.js similarity index 100% rename from packages/models/migrations/7-9_box_access_token/7-node-boxes-add-field-access-token.js rename to packages/models/_migrations/7-9_box_access_token/7-node-boxes-add-field-access-token.js diff --git a/packages/models/migrations/7-9_box_access_token/8-node-boxes-fix-sensors.js b/packages/models/_migrations/7-9_box_access_token/8-node-boxes-fix-sensors.js similarity index 100% rename from packages/models/migrations/7-9_box_access_token/8-node-boxes-fix-sensors.js rename to packages/models/_migrations/7-9_box_access_token/8-node-boxes-fix-sensors.js diff --git a/packages/models/migrations/7-9_box_access_token/9-node-boxes-add-field-useAuth.js b/packages/models/_migrations/7-9_box_access_token/9-node-boxes-add-field-useAuth.js similarity index 100% rename from packages/models/migrations/7-9_box_access_token/9-node-boxes-add-field-useAuth.js rename to packages/models/_migrations/7-9_box_access_token/9-node-boxes-add-field-useAuth.js diff --git a/packages/models/migrations/README.md b/packages/models/_migrations/README.md similarity index 100% rename from packages/models/migrations/README.md rename to packages/models/_migrations/README.md diff --git a/packages/models/drizzle.config.js b/packages/models/drizzle.config.js new file mode 100644 index 00000000..8284c608 --- /dev/null +++ b/packages/models/drizzle.config.js @@ -0,0 +1,37 @@ +'use strict'; + +const config = require('config'); + +config.util.setModuleDefaults('opensensemap-migrations', { + db: { + host: 'localhost', + port: 5432, + user: 'postgres', + userpass: 'postgres', + db: 'opensensemap', + database_url: '' + }, +}); + +const getDBUri = function getDBUri () { + // get uri from config + const uri = config.get('opensensemap-migrations.db.database_url'); + if (uri) { + return uri; + } + + // otherwise build uri from config supplied values + const { user, userpass, host, port, db } = config.get('opensensemap-migrations.db'); + + return `postgresql://${user}:${userpass}@${host}:${port}/${db}`; +}; + +/** @type { import("drizzle-kit").Config } */ +module.exports = { + schema: './schema/*', + out: './migrations', + dialect: 'postgresql', + dbCredentials: { + url: getDBUri() + } +}; diff --git a/packages/models/index.js b/packages/models/index.js index fb34435d..f89d8c28 100644 --- a/packages/models/index.js +++ b/packages/models/index.js @@ -9,12 +9,11 @@ const config = require('config'); config.util.setModuleDefaults('openSenseMap-API-models', { db: { host: 'localhost', - port: 27017, - user: 'admin', - userpass: 'admin', - authsource: 'OSeM-api', - db: 'OSeM-api', - mongo_uri: '', + port: 5432, + user: 'postgres', + userpass: 'postgres', + db: 'opensensemap', + database_url: '' }, integrations: { ca_cert: '', @@ -25,29 +24,29 @@ config.util.setModuleDefaults('openSenseMap-API-models', { port: 6379, username: '', password: '', - db: 0, + db: 0 }, mailer: { url: '', origin: '', - queue: 'mails', + queue: 'mails' }, mqtt: { - url: '', - }, + url: '' + } }, password: { min_length: 8, - salt_factor: 13, + salt_factor: 13 }, claims_ttl: { amount: 1, - unit: 'd', + unit: 'd' }, pagination: { max_boxes: 3 }, - image_folder: './userimages/', + image_folder: './userimages/' }); const { model: Box } = require('./src/box/box'), diff --git a/packages/models/package.json b/packages/models/package.json index 461cbb8b..c2ce90a0 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -7,11 +7,13 @@ "dependencies": { "@grpc/grpc-js": "^1.9.4", "@grpc/proto-loader": "^0.7.10", + "@paralleldrive/cuid2": "^2.2.2", "@sensebox/osem-protos": "^1.1.0", "@sensebox/sketch-templater": "1.13.1", "bcrypt": "^5.1.1", "bullmq": "^4.12.3", "config": "^3.3.6", + "drizzle-orm": "^0.33.0", "got": "^11.8.2", "isemail": "^3.0.0", "jsonpath": "^1.1.1", @@ -19,14 +21,22 @@ "moment": "^2.29.4", "mongoose": "^5.13.20", "mongoose-timestamp": "^0.6", + "pg": "^8.13.0", "pino": "^8.8.0", "uuid": "^8.3.2" }, "scripts": { + "db:generate": "drizzle-kit generate", + "db:generate-custom": "drizzle-kit generate --custom", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", "version": "node .scripts/npm_version-update_changelog.js && git add CHANGELOG.md", "test": "mocha test/waitForDatabase test/index.js" }, "publishConfig": { "access": "public" + }, + "devDependencies": { + "drizzle-kit": "^0.24.2" } } diff --git a/packages/models/schema/enum.js b/packages/models/schema/enum.js new file mode 100644 index 00000000..03978415 --- /dev/null +++ b/packages/models/schema/enum.js @@ -0,0 +1,26 @@ +'use strict'; + +const { pgEnum } = require('drizzle-orm/pg-core'); + +const deviceModel = pgEnum('model', [ + 'HOME_V2_LORA' +]); + +const exposure = pgEnum('exposure', [ + 'indoor', + 'outdoor', + 'mobile', + 'unknown' +]); + +const status = pgEnum('status', [ + 'active', + 'inactive', + 'old' +]); + +module.exports = { + deviceModel, + exposure, + status +}; diff --git a/packages/models/schema/measurement.js b/packages/models/schema/measurement.js new file mode 100644 index 00000000..e98c6c67 --- /dev/null +++ b/packages/models/schema/measurement.js @@ -0,0 +1,75 @@ +'use strict'; + +const { pgTable, text, timestamp, doublePrecision, unique, pgMaterializedView, integer } = require('drizzle-orm/pg-core'); + +/** + * Table definition + */ +const measurement = pgTable('measurement', { + sensorId: text('sensor_id').notNull(), + time: timestamp('time', { precision: 3, withTimezone: true }).defaultNow() + .notNull(), + value: doublePrecision('value') +}, (t) => ({ + unq: unique().on(t.sensorId, t.time) +})); + +/** + * Views + */ +const measurement10minView = pgMaterializedView('measurement_10min', { + sensorId: text('sensor_id'), + time: timestamp('time', { precision: 3, withTimezone: true }), + value: doublePrecision('avg_value'), + total_values: integer('total_values'), + min_value: doublePrecision('min_value'), + max_value: doublePrecision('max_value') +}).existing(); + +const measurements1hourView = pgMaterializedView('measurement_1hour', { + sensorId: text('sensor_id'), + time: timestamp('time', { precision: 3, withTimezone: true }), + value: doublePrecision('avg_value'), + total_values: integer('total_values'), + min_value: doublePrecision('min_value'), + max_value: doublePrecision('max_value') +}).existing(); + +const measurements1dayView = pgMaterializedView('measurement_1day', { + sensorId: text('sensor_id'), + time: timestamp('time', { precision: 3, withTimezone: true }), + value: doublePrecision('avg_value'), + total_values: integer('total_values'), + min_value: doublePrecision('min_value'), + max_value: doublePrecision('max_value') +}).existing(); + +const measurements1monthView = pgMaterializedView('measurement_1month', { + sensorId: text('sensor_id'), + time: timestamp('time', { precision: 3, withTimezone: true }), + value: doublePrecision('avg_value'), + total_values: integer('total_values'), + min_value: doublePrecision('min_value'), + max_value: doublePrecision('max_value') +}).existing(); + +const measurements1yearView = pgMaterializedView('measurement_1year', { + sensorId: text('sensor_id'), + time: timestamp('time', { precision: 3, withTimezone: true }), + value: doublePrecision('avg_value'), + total_values: integer('total_values'), + min_value: doublePrecision('min_value'), + max_value: doublePrecision('max_value') +}).existing(); + + +module.exports = { + table: measurement, + views: { + measurement10minView, + measurements1hourView, + measurements1dayView, + measurements1monthView, + measurements1yearView + } +}; diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js new file mode 100644 index 00000000..1b035d36 --- /dev/null +++ b/packages/models/schema/schema.js @@ -0,0 +1,161 @@ +'use strict'; + +const { pgTable, text, boolean, timestamp, doublePrecision, json } = require('drizzle-orm/pg-core'); +const { relations } = require('drizzle-orm'); +const { createId } = require('@paralleldrive/cuid2'); +const { exposure, status, deviceModel } = require('./enum'); +const { bytea } = require('./types'); + +/** + * Table definition + */ +const device = pgTable('device', { + id: text('id').primaryKey() + .notNull() + .$defaultFn(() => createId()), + name: text('name').notNull(), + image: text('image'), + description: text('description'), + link: text('link'), + useAuth: boolean('use_auth'), + exposure: exposure('exposure'), + status: status('status').default('inactive'), + model: deviceModel('model'), + public: boolean('public').default(false), + createdAt: timestamp('created_at').defaultNow() + .notNull(), + updatedAt: timestamp('updated_at').defaultNow() + .notNull(), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + userId: text('user_id').notNull(), + sensorWikiModel: text('sensor_wiki_model'), +}); + +const sensor = pgTable('sensor', { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + title: text('title'), + unit: text('unit'), + sensorType: text('sensor_type'), + status: status('status').default('inactive'), + createdAt: timestamp('created_at').defaultNow() + .notNull(), + updatedAt: timestamp('updated_at').defaultNow() + .notNull(), + deviceId: text('device_id').notNull(), + sensorWikiType: text('sensor_wiki_type'), + sensorWikiPhenomenon: text('sensor_wiki_phenomenon'), + sensorWikiUnit: text('sensor_wiki_unit'), + lastMeasurement: json('lastMeasurement'), + data: json('data') +}); + +const user = pgTable('user', { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + name: text('name').notNull(), + email: text('email').unique() + .notNull(), + role: text('role', { enum: ['admin', 'user'] }).default('user'), + language: text('language').default('en_US'), + emailIsConfirmed: boolean('email_is_confirmed').default(false), + createdAt: timestamp('created_at').defaultNow() + .notNull(), + updatedAt: timestamp('updated_at').defaultNow() + .notNull() +}); + +const password = pgTable('password', { + hash: text('hash').notNull(), + userId: text('user_id') + .references(() => user.id, { + onDelete: 'cascade', + onUpdate: 'cascade' + }) + .notNull() +}); + +const profile = pgTable('profile', { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + username: text('username').unique() + .notNull(), + public: boolean('public').default(false), + userId: text('user_id').references(() => user.id, { + onDelete: 'cascade', + onUpdate: 'cascade' + }) +}); + +const profileImage = pgTable('profile_image', { + id: text('id') + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + altText: text('alt_text'), + contentType: text('content_type').notNull(), + blob: bytea('blob').notNull(), + createdAt: timestamp('created_at').defaultNow() + .notNull(), + updatedAt: timestamp('updated_at').defaultNow() + .notNull(), + profileId: text('profile_id').references(() => profile.id, { + onDelete: 'cascade', + onUpdate: 'cascade' + }) +}); + +/** + * Relations + */ +const deviceRelations = relations(device, ({ many }) => ({ + sensors: many(sensor) +})); + +const sensorRelations = relations(sensor, ({ one }) => ({ + device: one(device, { + fields: [sensor.deviceId], + references: [device.id] + }) +})); + +const userRelations = relations(user, ({ one, many }) => ({ + password: one(password, { + fields: [user.id], + references: [password.userId] + }), + profile: one(profile, { + fields: [user.id], + references: [profile.userId] + }), + devices: many(device) +})); + +const profileRelations = relations(profile, ({ one }) => ({ + user: one(user, { + fields: [profile.userId], + references: [user.id] + }), + profileImage: one(profileImage, { + fields: [profile.id], + references: [profileImage.profileId] + }) +})); + +module.exports.deviceTable = device; +module.exports.sensorTable = sensor; +module.exports.userTable = user; +module.exports.passwordTable = password; +module.exports.profileTable = profile; +module.exports.profileImageTable = profileImage; +module.exports.deviceRelations = deviceRelations; +module.exports.sensorRelations = sensorRelations; +module.exports.userRelations = userRelations; +module.exports.profileRelations = profileRelations; diff --git a/packages/models/schema/types.js b/packages/models/schema/types.js new file mode 100644 index 00000000..87f64686 --- /dev/null +++ b/packages/models/schema/types.js @@ -0,0 +1,13 @@ +'use strict'; + +const { customType } = require('drizzle-orm/pg-core'); + +const bytea = customType({ + dataType () { + return 'bytea'; + } +}); + +module.exports = { + bytea +}; diff --git a/packages/models/src/db.js b/packages/models/src/db.js index cf12a50a..b1db4649 100644 --- a/packages/models/src/db.js +++ b/packages/models/src/db.js @@ -5,10 +5,14 @@ const config = require('config').get('openSenseMap-API-models.db'), // Bring Mongoose into the app const mongoose = require('mongoose'); +const { Client } = require('pg'); +const { drizzle } = require('drizzle-orm/node-postgres'); mongoose.Promise = global.Promise; mongoose.set('debug', process.env.NODE_ENV !== 'production'); +let drizzleClient; + const getDBUri = function getDBUri (uri) { // if available, use user specified db connection uri if (uri) { @@ -16,71 +20,89 @@ const getDBUri = function getDBUri (uri) { } // get uri from config - uri = config.get('mongo_uri'); + uri = config.get('database_url'); if (uri) { return uri; } // otherwise build uri from config supplied values - const { user, userpass, host, port, db, authsource } = config; + const { user, userpass, host, port, db } = config; - return `mongodb://${user}:${userpass}@${host}:${port}/${db}?authSource=${authsource}`; + return `postgresql://${user}:${userpass}@${host}:${port}/${db}`; }; -const connect = function connect (uri) { +const connect = async function connect (uri) { uri = getDBUri(uri); - mongoose.connection.on('connecting', function () { - log.info('trying to connect to MongoDB...'); - }); + try { + // const client = new Client({ + // connectionString: uri + // }); + + // await client.connect(); + // drizzleClient = drizzle(client); + + // return drizzle(client); + + // TODO attach event listener + + } catch (error) { + log.error(`Error ${error.message}`); + + throw new Error(error); + } + + // mongoose.connection.on('connecting', function () { + // log.info('trying to connect to MongoDB...'); + // }); // Create the database connection - return new Promise(function (resolve, reject) { - mongoose - .connect(uri, { - useNewUrlParser: true, - useUnifiedTopology: true, - promiseLibrary: global.Promise - }) - .then(function () { - // CONNECTION EVENTS - - // If the connection throws an error - mongoose.connection.on('error', function (err) { - log.error(err, 'Mongoose connection error'); - throw err; - }); - - // When the connection is disconnected - mongoose.connection.on('disconnected', function () { - log.warn('Mongoose connection disconnected. Retrying with mongo AutoReconnect.'); - }); - - // When the connection is resconnected - mongoose.connection.on('reconnected', function () { - log.info('Mongoose connection reconnected.'); - }); - - log.info('Successfully connected to MongoDB.'); - - return resolve(); - }) - .catch(function (err) { - // only called if the initial mongoose.connect fails on first connect - if (err.message.startsWith('failed to connect to server')) { - log.info(`Error ${err.message} - retrying manually in 1 second.`); - mongoose.connection.removeAllListeners(); - - return new Promise(function () { - setTimeout(function () { - resolve(connect()); - }, 1000); - }); - } - - return reject(err); - }); - }); + // return new Promise(function (resolve, reject) { + // mongoose + // .connect(uri, { + // useNewUrlParser: true, + // useUnifiedTopology: true, + // promiseLibrary: global.Promise + // }) + // .then(function () { + // // CONNECTION EVENTS + + // // If the connection throws an error + // mongoose.connection.on('error', function (err) { + // log.error(err, 'Mongoose connection error'); + // throw err; + // }); + + // // When the connection is disconnected + // mongoose.connection.on('disconnected', function () { + // log.warn('Mongoose connection disconnected. Retrying with mongo AutoReconnect.'); + // }); + + // // When the connection is resconnected + // mongoose.connection.on('reconnected', function () { + // log.info('Mongoose connection reconnected.'); + // }); + + // log.info('Successfully connected to MongoDB.'); + + // return resolve(); + // }) + // .catch(function (err) { + // // only called if the initial mongoose.connect fails on first connect + // if (err.message.startsWith('failed to connect to server')) { + // log.info(`Error ${err.message} - retrying manually in 1 second.`); + // mongoose.connection.removeAllListeners(); + + // return new Promise(function () { + // setTimeout(function () { + // resolve(connect()); + // }, 1000); + // }); + // } + + // return reject(err); + // }); + // }); }; module.exports = { diff --git a/packages/models/src/drizzle.js b/packages/models/src/drizzle.js new file mode 100644 index 00000000..83660ba9 --- /dev/null +++ b/packages/models/src/drizzle.js @@ -0,0 +1,40 @@ +'use strict'; + +const { drizzle } = require('drizzle-orm/node-postgres'); +const { Pool } = require('pg'); +const { + deviceTable, + sensorTable, + userTable, + passwordTable, + profileTable, + profileImageTable, + deviceRelations, + sensorRelations, + userRelations, + profileRelations +} = require('../schema/schema'); + + +const pool = new Pool({ + connectionString: 'postgresql://postgres:postgres@localhost:5432/opensensemap' +}); + +const schema = { + deviceTable, + sensorTable, + userTable, + passwordTable, + profileTable, + profileImageTable, + deviceRelations, + sensorRelations, + userRelations, + profileRelations +}; + +const db = drizzle(pool, { + schema +}); + +module.exports.db = db; diff --git a/packages/models/src/profile/profile.js b/packages/models/src/profile/profile.js new file mode 100644 index 00000000..e269281a --- /dev/null +++ b/packages/models/src/profile/profile.js @@ -0,0 +1,19 @@ +'use strict'; + +const { profileTable } = require('../../schema/schema'); +const { db } = require('../drizzle'); + +const createProfile = async function createProfile (user) { + + const { name, id } = user; + + return db.insert(profileTable).values({ + username: name, + public: false, + userId: id + }); +}; + +module.exports = { + createProfile: createProfile +}; diff --git a/packages/models/src/user/user.js b/packages/models/src/user/user.js index 4775ee68..f254f471 100644 --- a/packages/models/src/user/user.js +++ b/packages/models/src/user/user.js @@ -1,5 +1,6 @@ 'use strict'; +const { db } = require('../drizzle'); const integrations = require('./integrations'); /** @@ -25,6 +26,9 @@ const { mongoose } = require('../db'), ModelError = require('../modelError'), isemail = require('isemail'); +const { createProfile } = require('../profile/profile'); +const { userTable, passwordTable } = require('../../schema/schema'); + const userNameRequirementsText = 'Parameter name must consist of at least 3 and up to 40 alphanumerics (a-zA-Z0-9), dot (.), dash (-), underscore (_) and spaces.', userEmailRequirementsText = 'Parameter {PATH} is not a valid email address.'; @@ -301,7 +305,7 @@ userSchema.methods.addBox = function addBox (params) { const user = this; const serialPort = params.serialPort; - // initialize new box + // ialize new box return Box.initNew(params) .then(function (savedBox) { // request is valid @@ -716,7 +720,57 @@ integrations.addToSchema(userSchema); const userModel = mongoose.model('User', userSchema); +const createUser = async function createUser (name, email, password, language) { + try { + const hashedPassword = await passwordHasher(password); + const user = await db.insert(userTable).values({ name, email, language }) + .returning(); + + await db.insert(passwordTable).values({ + hash: hashedPassword, + userId: user[0].id + }); + + await createProfile(user[0]); + + // TODO: Only return specific fields + return user[0]; + } catch (error) { + console.log(error); + } +}; + +const deleteUser = async function deleteUser () {}; + +const updateUser = async function updateUser () {}; + +const findUserByNameOrEmail = async function findUserByNameOrEmail (emailOrName) { + return db.query.userTable.findFirst({ + where: (user, { eq, or }) => or(eq(user.email, emailOrName.toLowerCase()), eq(user.name, emailOrName)), + with: { + password: true + } + }); +}; + +const checkPassword = function checkPassword (plaintextPassword, hashedPassword) { + return bcrypt + .compare(preparePasswordHash(plaintextPassword), hashedPassword.hash) + .then(function (passwordIsCorrect) { + if (passwordIsCorrect === false) { + throw new ModelError('Password incorrect', { type: 'ForbiddenError' }); + } + + return true; + }); +}; + module.exports = { schema: userSchema, - model: userModel + model: userModel, + createUser, + deleteUser, + updateUser, + findUserByNameOrEmail, + checkPassword }; diff --git a/yarn.lock b/yarn.lock index 1dd7003a..88906540 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23,6 +23,252 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@drizzle-team/brocli@^0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@drizzle-team/brocli/-/brocli-0.10.1.tgz#8b73d65eaf2f6d04f45718ea6f4d789d69526cd3" + integrity sha512-AHy0vjc+n/4w/8Mif+w86qpppHuF3AyXbcWW+R/W7GNA3F5/p2nuhlkCJaTXSLZheB4l1rtHzOfr9A7NwoR/Zg== + +"@esbuild-kit/core-utils@^3.3.2": + version "3.3.2" + resolved "https://registry.yarnpkg.com/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz#186b6598a5066f0413471d7c4d45828e399ba96c" + integrity sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ== + dependencies: + esbuild "~0.18.20" + source-map-support "^0.5.21" + +"@esbuild-kit/esm-loader@^2.5.5": + version "2.6.5" + resolved "https://registry.yarnpkg.com/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz#6eedee46095d7d13b1efc381e2211ed1c60e64ea" + integrity sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA== + dependencies: + "@esbuild-kit/core-utils" "^3.3.2" + get-tsconfig "^4.7.0" + +"@esbuild/aix-ppc64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz#d1bc06aedb6936b3b6d313bf809a5a40387d2b7f" + integrity sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA== + +"@esbuild/android-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" + integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== + +"@esbuild/android-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz#7ad65a36cfdb7e0d429c353e00f680d737c2aed4" + integrity sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA== + +"@esbuild/android-arm@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" + integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== + +"@esbuild/android-arm@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824" + integrity sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w== + +"@esbuild/android-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" + integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg== + +"@esbuild/android-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.12.tgz#cb13e2211282012194d89bf3bfe7721273473b3d" + integrity sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew== + +"@esbuild/darwin-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" + integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA== + +"@esbuild/darwin-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz#cbee41e988020d4b516e9d9e44dd29200996275e" + integrity sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g== + +"@esbuild/darwin-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" + integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ== + +"@esbuild/darwin-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz#e37d9633246d52aecf491ee916ece709f9d5f4cd" + integrity sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A== + +"@esbuild/freebsd-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" + integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw== + +"@esbuild/freebsd-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz#1ee4d8b682ed363b08af74d1ea2b2b4dbba76487" + integrity sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA== + +"@esbuild/freebsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" + integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ== + +"@esbuild/freebsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz#37a693553d42ff77cd7126764b535fb6cc28a11c" + integrity sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg== + +"@esbuild/linux-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" + integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA== + +"@esbuild/linux-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz#be9b145985ec6c57470e0e051d887b09dddb2d4b" + integrity sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA== + +"@esbuild/linux-arm@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" + integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg== + +"@esbuild/linux-arm@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz#207ecd982a8db95f7b5279207d0ff2331acf5eef" + integrity sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w== + +"@esbuild/linux-ia32@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" + integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA== + +"@esbuild/linux-ia32@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz#d0d86b5ca1562523dc284a6723293a52d5860601" + integrity sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA== + +"@esbuild/linux-loong64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d" + integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg== + +"@esbuild/linux-loong64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299" + integrity sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA== + +"@esbuild/linux-mips64el@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" + integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ== + +"@esbuild/linux-mips64el@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz#4ddebd4e6eeba20b509d8e74c8e30d8ace0b89ec" + integrity sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w== + +"@esbuild/linux-ppc64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" + integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA== + +"@esbuild/linux-ppc64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz#adb67dadb73656849f63cd522f5ecb351dd8dee8" + integrity sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg== + +"@esbuild/linux-riscv64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" + integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A== + +"@esbuild/linux-riscv64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz#11bc0698bf0a2abf8727f1c7ace2112612c15adf" + integrity sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg== + +"@esbuild/linux-s390x@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" + integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ== + +"@esbuild/linux-s390x@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz#e86fb8ffba7c5c92ba91fc3b27ed5a70196c3cc8" + integrity sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg== + +"@esbuild/linux-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" + integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w== + +"@esbuild/linux-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz#5f37cfdc705aea687dfe5dfbec086a05acfe9c78" + integrity sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg== + +"@esbuild/netbsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" + integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A== + +"@esbuild/netbsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz#29da566a75324e0d0dd7e47519ba2f7ef168657b" + integrity sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA== + +"@esbuild/openbsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" + integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg== + +"@esbuild/openbsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz#306c0acbdb5a99c95be98bdd1d47c916e7dc3ff0" + integrity sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw== + +"@esbuild/sunos-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" + integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ== + +"@esbuild/sunos-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz#0933eaab9af8b9b2c930236f62aae3fc593faf30" + integrity sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA== + +"@esbuild/win32-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" + integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg== + +"@esbuild/win32-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz#773bdbaa1971b36db2f6560088639ccd1e6773ae" + integrity sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A== + +"@esbuild/win32-ia32@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" + integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g== + +"@esbuild/win32-ia32@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz#000516cad06354cc84a73f0943a4aa690ef6fd67" + integrity sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ== + +"@esbuild/win32-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" + integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ== + +"@esbuild/win32-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae" + integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA== + "@eslint/eslintrc@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.1.tgz#442763b88cecbe3ee0ec7ca6d6dd6168550cbf14" @@ -115,6 +361,18 @@ extsprintf "^1.4.0" lodash "^4.17.15" +"@noble/hashes@^1.1.5": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.5.0.tgz#abadc5ca20332db2b1b2aa3e496e9af1213570b0" + integrity sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA== + +"@paralleldrive/cuid2@^2.2.2": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz#7f91364d53b89e2c9cb9e02e8dd0f129e834455f" + integrity sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA== + dependencies: + "@noble/hashes" "^1.1.5" + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -1322,6 +1580,21 @@ domutils@^2.5.1, domutils@^2.5.2: domelementtype "^2.2.0" domhandler "^4.1.0" +drizzle-kit@^0.24.2: + version "0.24.2" + resolved "https://registry.yarnpkg.com/drizzle-kit/-/drizzle-kit-0.24.2.tgz#928a56a6a2bec1c5725321f559ec51dc5a943412" + integrity sha512-nXOaTSFiuIaTMhS8WJC2d4EBeIcN9OSt2A2cyFbQYBAZbi7lRsVGJNqDpEwPqYfJz38yxbY/UtbvBBahBfnExQ== + dependencies: + "@drizzle-team/brocli" "^0.10.1" + "@esbuild-kit/esm-loader" "^2.5.5" + esbuild "^0.19.7" + esbuild-register "^3.5.0" + +drizzle-orm@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/drizzle-orm/-/drizzle-orm-0.33.0.tgz#ece81e3e85f7559b5f7c01fc09e654e9a2f087fe" + integrity sha512-SHy72R2Rdkz0LEq0PSG/IdvnT3nGiWuRk+2tXZQ90GVq/XQhpCzu/EFT3V2rox+w8MlkBQxifF8pCStNYnERfA== + dtrace-provider@~0.8: version "0.8.8" resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.8.tgz#2996d5490c37e1347be263b423ed7b297fb0d97e" @@ -1400,6 +1673,70 @@ eol@^0.9.1: resolved "https://registry.yarnpkg.com/eol/-/eol-0.9.1.tgz#f701912f504074be35c6117a5c4ade49cd547acd" integrity sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg== +esbuild-register@^3.5.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/esbuild-register/-/esbuild-register-3.6.0.tgz#cf270cfa677baebbc0010ac024b823cbf723a36d" + integrity sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg== + dependencies: + debug "^4.3.4" + +esbuild@^0.19.7: + version "0.19.12" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.12.tgz#dc82ee5dc79e82f5a5c3b4323a2a641827db3e04" + integrity sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.19.12" + "@esbuild/android-arm" "0.19.12" + "@esbuild/android-arm64" "0.19.12" + "@esbuild/android-x64" "0.19.12" + "@esbuild/darwin-arm64" "0.19.12" + "@esbuild/darwin-x64" "0.19.12" + "@esbuild/freebsd-arm64" "0.19.12" + "@esbuild/freebsd-x64" "0.19.12" + "@esbuild/linux-arm" "0.19.12" + "@esbuild/linux-arm64" "0.19.12" + "@esbuild/linux-ia32" "0.19.12" + "@esbuild/linux-loong64" "0.19.12" + "@esbuild/linux-mips64el" "0.19.12" + "@esbuild/linux-ppc64" "0.19.12" + "@esbuild/linux-riscv64" "0.19.12" + "@esbuild/linux-s390x" "0.19.12" + "@esbuild/linux-x64" "0.19.12" + "@esbuild/netbsd-x64" "0.19.12" + "@esbuild/openbsd-x64" "0.19.12" + "@esbuild/sunos-x64" "0.19.12" + "@esbuild/win32-arm64" "0.19.12" + "@esbuild/win32-ia32" "0.19.12" + "@esbuild/win32-x64" "0.19.12" + +esbuild@~0.18.20: + version "0.18.20" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6" + integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA== + optionalDependencies: + "@esbuild/android-arm" "0.18.20" + "@esbuild/android-arm64" "0.18.20" + "@esbuild/android-x64" "0.18.20" + "@esbuild/darwin-arm64" "0.18.20" + "@esbuild/darwin-x64" "0.18.20" + "@esbuild/freebsd-arm64" "0.18.20" + "@esbuild/freebsd-x64" "0.18.20" + "@esbuild/linux-arm" "0.18.20" + "@esbuild/linux-arm64" "0.18.20" + "@esbuild/linux-ia32" "0.18.20" + "@esbuild/linux-loong64" "0.18.20" + "@esbuild/linux-mips64el" "0.18.20" + "@esbuild/linux-ppc64" "0.18.20" + "@esbuild/linux-riscv64" "0.18.20" + "@esbuild/linux-s390x" "0.18.20" + "@esbuild/linux-x64" "0.18.20" + "@esbuild/netbsd-x64" "0.18.20" + "@esbuild/openbsd-x64" "0.18.20" + "@esbuild/sunos-x64" "0.18.20" + "@esbuild/win32-arm64" "0.18.20" + "@esbuild/win32-ia32" "0.18.20" + "@esbuild/win32-x64" "0.18.20" + escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -1812,6 +2149,13 @@ get-stream@^5.1.0: dependencies: pump "^3.0.0" +get-tsconfig@^4.7.0: + version "4.8.1" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.8.1.tgz#8995eb391ae6e1638d251118c7b56de7eb425471" + integrity sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg== + dependencies: + resolve-pkg-maps "^1.0.0" + getpass@^0.1.1: version "0.1.7" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" @@ -2916,6 +3260,62 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +pg-cloudflare@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" + integrity sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q== + +pg-connection-string@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.7.0.tgz#f1d3489e427c62ece022dba98d5262efcb168b37" + integrity sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.7.0.tgz#d4d3c7ad640f8c6a2245adc369bafde4ebb8cbec" + integrity sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g== + +pg-protocol@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.7.0.tgz#ec037c87c20515372692edac8b63cf4405448a93" + integrity sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ== + +pg-types@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@^8.13.0: + version "8.13.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.0.tgz#e3d245342eb0158112553fcc1890a60720ae2a3d" + integrity sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw== + dependencies: + pg-connection-string "^2.7.0" + pg-pool "^3.7.0" + pg-protocol "^1.7.0" + pg-types "^2.1.0" + pgpass "1.x" + optionalDependencies: + pg-cloudflare "^1.1.1" + +pgpass@1.x: + version "1.0.5" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" + integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== + dependencies: + split2 "^4.1.0" + picomatch@^2.0.4, picomatch@^2.2.1: version "2.2.2" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" @@ -3022,6 +3422,28 @@ polygon-clipping@^0.15.3: dependencies: splaytree "^3.1.0" +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -3319,6 +3741,11 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + responselike@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.0.tgz#26391bcc3174f750f9a79eacc40a12a5c42d7723" @@ -3539,7 +3966,15 @@ sonic-boom@^4.0.1: dependencies: atomic-sleep "^1.0.0" -source-map@~0.6.1: +source-map-support@^0.5.21: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -3584,7 +4019,7 @@ split2@^4.0.0: resolved "https://registry.yarnpkg.com/split2/-/split2-4.1.0.tgz#101907a24370f85bb782f08adaabe4e281ecf809" integrity sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ== -split2@^4.2.0: +split2@^4.1.0, split2@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== @@ -4009,6 +4444,11 @@ ws@^8.13.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + y18n@^5.0.5: version "5.0.5" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.5.tgz#8769ec08d03b1ea2df2500acef561743bbb9ab18" From f90bec2d8aec82e73fc1e27dafce4235c058d448 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Wed, 25 Sep 2024 15:16:08 +0200 Subject: [PATCH 02/34] add drizzle migrations --- .../models/migrations/0000_init_table.sql | 112 ++++ packages/models/migrations/0001_init_tsdb.sql | 116 ++++ .../models/migrations/meta/0000_snapshot.json | 518 ++++++++++++++++++ .../models/migrations/meta/0001_snapshot.json | 518 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 20 + 5 files changed, 1284 insertions(+) create mode 100644 packages/models/migrations/0000_init_table.sql create mode 100644 packages/models/migrations/0001_init_tsdb.sql create mode 100644 packages/models/migrations/meta/0000_snapshot.json create mode 100644 packages/models/migrations/meta/0001_snapshot.json create mode 100644 packages/models/migrations/meta/_journal.json diff --git a/packages/models/migrations/0000_init_table.sql b/packages/models/migrations/0000_init_table.sql new file mode 100644 index 00000000..89f136ec --- /dev/null +++ b/packages/models/migrations/0000_init_table.sql @@ -0,0 +1,112 @@ +DO $$ BEGIN + CREATE TYPE "public"."model" AS ENUM('HOME_V2_LORA'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."exposure" AS ENUM('indoor', 'outdoor', 'mobile', 'unknown'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."status" AS ENUM('active', 'inactive', 'old'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "device" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "image" text, + "description" text, + "link" text, + "use_auth" boolean, + "exposure" "exposure", + "status" "status" DEFAULT 'inactive', + "model" "model", + "public" boolean DEFAULT false, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "latitude" double precision NOT NULL, + "longitude" double precision NOT NULL, + "user_id" text NOT NULL, + "sensor_wiki_model" text +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "measurement" ( + "sensor_id" text NOT NULL, + "time" timestamp (3) with time zone DEFAULT now() NOT NULL, + "value" double precision, + CONSTRAINT "measurement_sensor_id_time_unique" UNIQUE("sensor_id","time") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "password" ( + "hash" text NOT NULL, + "user_id" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "profile_image" ( + "id" text PRIMARY KEY NOT NULL, + "alt_text" text, + "content_type" text NOT NULL, + "blob" "bytea" NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "profile_id" text +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "profile" ( + "id" text PRIMARY KEY NOT NULL, + "username" text NOT NULL, + "public" boolean DEFAULT false, + "user_id" text, + CONSTRAINT "profile_username_unique" UNIQUE("username") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "sensor" ( + "id" text PRIMARY KEY NOT NULL, + "title" text, + "unit" text, + "sensor_type" text, + "status" "status" DEFAULT 'inactive', + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "device_id" text NOT NULL, + "sensor_wiki_type" text, + "sensor_wiki_phenomenon" text, + "sensor_wiki_unit" text, + "lastMeasurement" json, + "data" json +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "role" text DEFAULT 'user', + "language" text DEFAULT 'en_US', + "email_is_confirmed" boolean DEFAULT false, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "password" ADD CONSTRAINT "password_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "profile_image" ADD CONSTRAINT "profile_image_profile_id_profile_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE cascade; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "profile" ADD CONSTRAINT "profile_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/models/migrations/0001_init_tsdb.sql b/packages/models/migrations/0001_init_tsdb.sql new file mode 100644 index 00000000..a00429df --- /dev/null +++ b/packages/models/migrations/0001_init_tsdb.sql @@ -0,0 +1,116 @@ +-- This is a custom SQL migration file! -- + +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; + +-- Turn the measurement table into a hypertable +SELECT create_hypertable('measurement', 'time'); + +-- Drop raw data older than 1 year +SELECT add_retention_policy('measurement', INTERVAL '1 year'); + +-- Continuous aggregate (CAGG) of the hypertable +-- https://docs.timescale.com/use-timescale/latest/continuous-aggregates/real-time-aggregates/ +CREATE MATERIALIZED VIEW measurement_10min WITH (timescaledb.continuous) AS +SELECT measurement.sensor_id, + time_bucket('10 minutes', measurement.time) AS time, + COUNT(*) total_values, + AVG(measurement.value) AS avg_value, + percentile_agg(measurement.value) as percentile_10min, + MAX(measurement.value) AS max_value, + MIN(measurement.value) AS min_value +FROM measurement +GROUP BY 1, 2 +WITH NO DATA; + +-- Add a CAGG policy in order to refresh it automatically +-- Automatically keep downsampled data up to date with new data from 20 minutes to 2 days ago. +-- https://docs.timescale.com/use-timescale/latest/continuous-aggregates/drop-data/ +SELECT add_continuous_aggregate_policy('measurement_10min', + start_offset => INTERVAL '2 days', + end_offset => INTERVAL '10 minutes', + schedule_interval => INTERVAL '10 minutes' +); + +-- Continuous aggregate (CAGG) of the hypertable +-- https://docs.timescale.com/use-timescale/latest/continuous-aggregates/real-time-aggregates/ +CREATE MATERIALIZED VIEW measurement_1hour WITH (timescaledb.continuous) AS +SELECT sensor_id, + time_bucket('1 hour', time) AS time, + COUNT(*) total_values, + mean(rollup(percentile_10min)) AS avg_value, + rollup(percentile_10min) as percentile_1hour, + MAX(max_value) AS max_value, + MIN(min_value) AS min_value +FROM measurement_10min +GROUP BY 1, 2 +WITH NO DATA; + +SELECT add_continuous_aggregate_policy('measurement_1hour', + start_offset => INTERVAL '2 days', + end_offset => INTERVAL '3 minutes', + schedule_interval => INTERVAL '1 hour', + initial_start => date_trunc('hours', now() + INTERVAL '1 hour') + INTERVAL '5 minutes' +); + +-- Continuous aggregate (CAGG) on top of another CAGG / Hierarchical Continuous Aggregates , new in Timescale 2.9, issue with TZ as of https://github.com/timescale/timescaledb/pull/5195 +-- https://docs.timescale.com/use-timescale/latest/continuous-aggregates/real-time-aggregates/ +CREATE MATERIALIZED VIEW measurement_1day WITH (timescaledb.continuous) AS +SELECT sensor_id, + time_bucket('1 day', time) AS time, + COUNT(*) total_values, + mean(rollup(percentile_1hour)) AS avg_value, + rollup(percentile_1hour) as percentile_1day, + MAX(max_value) AS max_value, + MIN(min_value) AS min_value +FROM measurement_1hour +GROUP BY 1, 2 +WITH NO DATA; + +-- Add a CAGG policy in order to refresh it automatically +-- Automatically keep downsampled data up to date with new data from 2 days to 4 days ago. +-- https://docs.timescale.com/use-timescale/latest/continuous-aggregates/drop-data/ +SELECT add_continuous_aggregate_policy('measurement_1day', + start_offset => INTERVAL '3 days', + end_offset => INTERVAL '3 minutes', + schedule_interval => INTERVAL '1 day', + initial_start => date_trunc('day', now() + INTERVAL '1 day') + INTERVAL '5 minutes' +); + +CREATE MATERIALIZED VIEW measurement_1month WITH (timescaledb.continuous) AS +SELECT sensor_id, + time_bucket('1 month', time) AS time, + COUNT(*) total_values, + mean(rollup(percentile_1day)) AS avg_value, + rollup(percentile_1day) as percentile_1month, + MAX(max_value) AS max_value, + MIN(min_value) AS min_value +FROM measurement_1day +GROUP BY 1, 2 +WITH NO DATA; + +SELECT add_continuous_aggregate_policy('measurement_1month', + start_offset => INTERVAL '3 months', + end_offset => INTERVAL '3 minutes', + schedule_interval => INTERVAL '1 month', + initial_start => date_trunc('month', now() + INTERVAL '1 month') + INTERVAL '5 minutes' +); + +CREATE MATERIALIZED VIEW measurement_1year WITH (timescaledb.continuous) AS +SELECT sensor_id, + time_bucket('1 year', time) AS time, + COUNT(*) total_values, + mean(rollup(percentile_1day)) AS avg_value, + rollup(percentile_1day) as percentile_1year, + MAX(max_value) AS max_value, + MIN(min_value) AS min_value +FROM measurement_1day +GROUP BY 1, 2 +WITH NO DATA; + +SELECT add_continuous_aggregate_policy('measurement_1year', + start_offset => INTERVAL '3 years', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '1 year', + initial_start => date_trunc('year', now() + INTERVAL '1 year') + INTERVAL '2 hours' +); \ No newline at end of file diff --git a/packages/models/migrations/meta/0000_snapshot.json b/packages/models/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..f7d09aee --- /dev/null +++ b/packages/models/migrations/meta/0000_snapshot.json @@ -0,0 +1,518 @@ +{ + "id": "4f13d236-bd47-4bd5-858f-e17ec12a0e50", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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": {} + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "HOME_V2_LORA" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/0001_snapshot.json b/packages/models/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..67d07f57 --- /dev/null +++ b/packages/models/migrations/meta/0001_snapshot.json @@ -0,0 +1,518 @@ +{ + "id": "6fbcacd9-3875-4f2c-8866-54dd0fa1b476", + "prevId": "4f13d236-bd47-4bd5-858f-e17ec12a0e50", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "columns": [ + "sensor_id", + "time" + ], + "nullsNotDistinct": false + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "columnsFrom": [ + "profile_id" + ], + "tableTo": "profile", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + } + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + } + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "HOME_V2_LORA" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json new file mode 100644 index 00000000..7ed73bce --- /dev/null +++ b/packages/models/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1727183815665, + "tag": "0000_init_table", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1727196569376, + "tag": "0001_init_tsdb", + "breakpoints": true + } + ] +} \ No newline at end of file From 64ff906ccc8ec9d0c57e87d9d4443c48f24fdc41 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Wed, 25 Sep 2024 23:32:23 +0200 Subject: [PATCH 03/34] update password reset functionality --- .../api/lib/controllers/usersController.js | 4 +- .../0002_add_password_reset_table.sql | 12 + .../models/migrations/meta/0002_snapshot.json | 568 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 7 + packages/models/package.json | 1 + packages/models/schema/schema.js | 20 + packages/models/src/drizzle.js | 2 + packages/models/src/user/user.js | 24 +- 8 files changed, 634 insertions(+), 4 deletions(-) create mode 100644 packages/models/migrations/0002_add_password_reset_table.sql create mode 100644 packages/models/migrations/meta/0002_snapshot.json diff --git a/packages/api/lib/controllers/usersController.js b/packages/api/lib/controllers/usersController.js index 834c4a52..3e966788 100644 --- a/packages/api/lib/controllers/usersController.js +++ b/packages/api/lib/controllers/usersController.js @@ -15,7 +15,7 @@ const { User } = require('@sensebox/opensensemap-api-models'), refreshJwt, invalidateToken, } = require('../helpers/jwtHelpers'); -const { createUser, findUserByNameOrEmail, checkPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); +const { createUser, findUserByNameOrEmail, checkPassword, initPasswordReset } = require('@sensebox/opensensemap-api-models/src/user/user'); /** * define for nested user parameter for box creation request @@ -188,7 +188,7 @@ const signOut = async function signOut (req, res) { // generate new password reset token and send the token to the user const requestResetPassword = async function requestResetPassword (req, res) { try { - await User.initPasswordReset(req._userParams); + await initPasswordReset(req._userParams); res.send(200, { code: 'Ok', message: 'Password reset initiated' }); } catch (err) { return handleError(err); diff --git a/packages/models/migrations/0002_add_password_reset_table.sql b/packages/models/migrations/0002_add_password_reset_table.sql new file mode 100644 index 00000000..d4081d04 --- /dev/null +++ b/packages/models/migrations/0002_add_password_reset_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS "password_reset" ( + "user_id" text NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp NOT NULL, + CONSTRAINT "password_reset_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "password_reset" ADD CONSTRAINT "password_reset_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/models/migrations/meta/0002_snapshot.json b/packages/models/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..d342b7a8 --- /dev/null +++ b/packages/models/migrations/meta/0002_snapshot.json @@ -0,0 +1,568 @@ +{ + "id": "ea325078-5292-43a6-be9e-3b499c32eba5", + "prevId": "6fbcacd9-3875-4f2c-8866-54dd0fa1b476", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "HOME_V2_LORA" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json index 7ed73bce..f5bfdad3 100644 --- a/packages/models/migrations/meta/_journal.json +++ b/packages/models/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1727196569376, "tag": "0001_init_tsdb", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1727299848109, + "tag": "0002_add_password_reset_table", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/models/package.json b/packages/models/package.json index c2ce90a0..58a157b5 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -26,6 +26,7 @@ "uuid": "^8.3.2" }, "scripts": { + "db:drop": "drizzle-kit drop", "db:generate": "drizzle-kit generate", "db:generate-custom": "drizzle-kit generate --custom", "db:migrate": "drizzle-kit migrate", diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js index 1b035d36..1f51f505 100644 --- a/packages/models/schema/schema.js +++ b/packages/models/schema/schema.js @@ -3,6 +3,8 @@ const { pgTable, text, boolean, timestamp, doublePrecision, json } = require('drizzle-orm/pg-core'); const { relations } = require('drizzle-orm'); const { createId } = require('@paralleldrive/cuid2'); +const { v4: uuidv4 } = require('uuid'); +const moment = require('moment'); const { exposure, status, deviceModel } = require('./enum'); const { bytea } = require('./types'); @@ -80,6 +82,19 @@ const password = pgTable('password', { .notNull() }); +const passwordReset = pgTable('password_reset', { + userId: text('user_id').unique() + .notNull() + .references(() => user.id, { + onDelete: 'cascade', + }), + token: text('token').notNull() + .$defaultFn(() => uuidv4()), + expiresAt: timestamp('expires_at').notNull() + .$defaultFn(() => moment.utc().add(12, 'hours') + .toDate()) +}); + const profile = pgTable('profile', { id: text('id') .primaryKey() @@ -131,6 +146,10 @@ const userRelations = relations(user, ({ one, many }) => ({ fields: [user.id], references: [password.userId] }), + passwordReset: one(passwordReset, { + fields: [user.id], + references: [passwordReset.userId] + }), profile: one(profile, { fields: [user.id], references: [profile.userId] @@ -153,6 +172,7 @@ module.exports.deviceTable = device; module.exports.sensorTable = sensor; module.exports.userTable = user; module.exports.passwordTable = password; +module.exports.passwordResetTable = passwordReset; module.exports.profileTable = profile; module.exports.profileImageTable = profileImage; module.exports.deviceRelations = deviceRelations; diff --git a/packages/models/src/drizzle.js b/packages/models/src/drizzle.js index 83660ba9..06bff050 100644 --- a/packages/models/src/drizzle.js +++ b/packages/models/src/drizzle.js @@ -7,6 +7,7 @@ const { sensorTable, userTable, passwordTable, + passwordResetTable, profileTable, profileImageTable, deviceRelations, @@ -25,6 +26,7 @@ const schema = { sensorTable, userTable, passwordTable, + passwordResetTable, profileTable, profileImageTable, deviceRelations, diff --git a/packages/models/src/user/user.js b/packages/models/src/user/user.js index f254f471..9e9615c5 100644 --- a/packages/models/src/user/user.js +++ b/packages/models/src/user/user.js @@ -27,7 +27,7 @@ const { mongoose } = require('../db'), isemail = require('isemail'); const { createProfile } = require('../profile/profile'); -const { userTable, passwordTable } = require('../../schema/schema'); +const { userTable, passwordTable, passwordResetTable } = require('../../schema/schema'); const userNameRequirementsText = 'Parameter name must consist of at least 3 and up to 40 alphanumerics (a-zA-Z0-9), dot (.), dash (-), underscore (_) and spaces.', userEmailRequirementsText = 'Parameter {PATH} is not a valid email address.'; @@ -765,6 +765,25 @@ const checkPassword = function checkPassword (plaintextPassword, hashedPassword) }); }; +const initPasswordReset = async function initPasswordReset ({ email }) { + + const user = await db.query.userTable.findFirst({ + where: (user, { eq }) => eq(user.email, email.toLowerCase()) + }); + + if (!user) { + throw new ModelError('Password reset for this user not possible', { type: 'ForbiddenError' }); + } + + // Create entry with default values + await db.insert(passwordResetTable).values({ userId: user.id }) + .onConflictDoUpdate({ target: passwordResetTable.userId, set: { + token: uuidv4(), + expiresAt: moment.utc().add(12, 'hours') + .toDate() + } }); +}; + module.exports = { schema: userSchema, model: userModel, @@ -772,5 +791,6 @@ module.exports = { deleteUser, updateUser, findUserByNameOrEmail, - checkPassword + checkPassword, + initPasswordReset }; From e9d2651364a10e2d8a633536b6b0b9b078b7467f Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Wed, 25 Sep 2024 23:48:40 +0200 Subject: [PATCH 04/34] update database in tests --- tests/docker-compose.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 13b020fe..4f4e11c6 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -1,4 +1,3 @@ -version: "2.1" volumes: userimages: services: @@ -20,7 +19,7 @@ services: "slack_url": "http://127.0.0.1/bla/bli/blu", "openSenseMap-API-models": { "db": { - "mongo_uri": "mongodb://db/api-test" + "database_url": "postgresql://postgres:postgres@db:5432/opensensemap" }, "password": { "salt_factor": 1 @@ -97,7 +96,7 @@ services: }, "openSenseMap-API-models": { "db": { - "mongo_uri": "mongodb://db/api-test" + "database_url": "postgresql://postgres:postgres@db:5432/opensensemap" } } } @@ -117,4 +116,13 @@ services: - 6379:6379 db: - image: mongo:${MONGO_TAG:-5} + image: timescale/timescaledb-ha:pg15-latest + command: + - -cshared_preload_libraries=timescaledb,pg_cron + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=opensensemap + ports: + - 5432:5432 From c17d04bf803e840c155fe36c6e4edd7374475287 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Fri, 27 Sep 2024 11:49:59 +0200 Subject: [PATCH 05/34] more postgres updates --- .../api/lib/controllers/boxesController.js | 18 +- .../api/lib/controllers/usersController.js | 5 +- packages/api/lib/helpers/jwtHelpers.js | 28 +- packages/api/lib/helpers/tokenBlacklist.js | 1 + ...003_add_cronjob_reset_password_expires.sql | 1 + .../models/migrations/0004_add_updateOn.sql | 14 + .../0005_add_device_access_token.sql | 13 + .../migrations/0006_add_ref_sensor_device.sql | 5 + .../models/migrations/meta/0003_snapshot.json | 568 +++++++++++++++ .../models/migrations/meta/0004_snapshot.json | 632 ++++++++++++++++ .../models/migrations/meta/0005_snapshot.json | 674 +++++++++++++++++ .../models/migrations/meta/0006_snapshot.json | 688 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 28 + packages/models/schema/schema.js | 71 +- packages/models/src/device/index.js | 74 ++ packages/models/src/drizzle.js | 8 +- packages/models/src/token/refresh.js | 22 + packages/models/src/user/user.js | 47 +- 18 files changed, 2860 insertions(+), 37 deletions(-) create mode 100644 packages/models/migrations/0003_add_cronjob_reset_password_expires.sql create mode 100644 packages/models/migrations/0004_add_updateOn.sql create mode 100644 packages/models/migrations/0005_add_device_access_token.sql create mode 100644 packages/models/migrations/0006_add_ref_sensor_device.sql create mode 100644 packages/models/migrations/meta/0003_snapshot.json create mode 100644 packages/models/migrations/meta/0004_snapshot.json create mode 100644 packages/models/migrations/meta/0005_snapshot.json create mode 100644 packages/models/migrations/meta/0006_snapshot.json create mode 100644 packages/models/src/device/index.js create mode 100644 packages/models/src/token/refresh.js diff --git a/packages/api/lib/controllers/boxesController.js b/packages/api/lib/controllers/boxesController.js index 029546b2..cddfea93 100644 --- a/packages/api/lib/controllers/boxesController.js +++ b/packages/api/lib/controllers/boxesController.js @@ -70,6 +70,7 @@ const } = require('../helpers/userParamHelpers'), handleError = require('../helpers/errorHandler'), jsonstringify = require('stringify-stream'); +const { createDevice } = require('@sensebox/opensensemap-api-models/src/device'); /** * @apiDefine Addons @@ -499,18 +500,21 @@ const getBox = async function getBox (req, res) { */ const postNewBox = async function postNewBox (req, res) { try { - let newBox = await req.user.addBox(req._userParams); - newBox = await Box.populate(newBox, Box.BOX_SUB_PROPS_FOR_POPULATION); - res.send(201, { message: 'Box successfully created', data: newBox }); + // let newBox = await req.user.addBox(req._userParams); + // newBox = await Box.populate(newBox, Box.BOX_SUB_PROPS_FOR_POPULATION); + + const newDevice = await createDevice(req.user.id, req._userParams); + + res.send(201, { message: 'Box successfully created', data: newDevice }); clearCache(['getBoxes', 'getStats']); postToMattermost( `New Box: ${req.user.name} (${redactEmail( req.user.email - )}) just registered "${newBox.name}" (${ - newBox.model + )}) just registered "${newDevice.name}" (${ + newDevice.model }): [https://opensensemap.org/explore/${ - newBox._id - }](https://opensensemap.org/explore/${newBox._id})` + newDevice.id + }](https://opensensemap.org/explore/${newDevice.id})` ); } catch (err) { return handleError(err); diff --git a/packages/api/lib/controllers/usersController.js b/packages/api/lib/controllers/usersController.js index 3e966788..f1eef567 100644 --- a/packages/api/lib/controllers/usersController.js +++ b/packages/api/lib/controllers/usersController.js @@ -15,7 +15,7 @@ const { User } = require('@sensebox/opensensemap-api-models'), refreshJwt, invalidateToken, } = require('../helpers/jwtHelpers'); -const { createUser, findUserByNameOrEmail, checkPassword, initPasswordReset } = require('@sensebox/opensensemap-api-models/src/user/user'); +const { createUser, findUserByNameOrEmail, checkPassword, initPasswordReset, resetOldPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); /** * define for nested user parameter for box creation request @@ -208,7 +208,8 @@ const requestResetPassword = async function requestResetPassword (req, res) { // set new password with reset token as auth const resetPassword = async function resetPassword (req, res) { try { - await User.resetPassword(req._userParams); + // await User.resetPassword(req._userParams); + await resetOldPassword(req._userParams); res.send(200, { code: 'Ok', message: diff --git a/packages/api/lib/helpers/jwtHelpers.js b/packages/api/lib/helpers/jwtHelpers.js index 132989cb..50db4f07 100644 --- a/packages/api/lib/helpers/jwtHelpers.js +++ b/packages/api/lib/helpers/jwtHelpers.js @@ -1,5 +1,7 @@ 'use strict'; +const { addRefreshToken, deleteRefreshToken } = require('@sensebox/opensensemap-api-models/src/token/refresh'); +const { findUserByEmailAndRole } = require('@sensebox/opensensemap-api-models/src/user/user'); const config = require('config'), jwt = require('jsonwebtoken'), hashJWT = require('./jwtRefreshTokenHasher'), @@ -38,19 +40,10 @@ const createToken = function createToken (user) { // and set the refreshTokenExpires to 1 week // it is a HMAC of the jwt string const refreshToken = hashJWT(token); + const refreshTokenExpiresAt = moment.utc().add(Number(refresh_token_validity_ms), 'ms') + .toDate(); try { - // TODO: do we need a new table for tokens??? - user.refreshToken = refreshToken; - user.refreshTokenExpires = moment.utc().add(Number(refresh_token_validity_ms), 'ms') - .toDate(); - // await user.update({ - // $set: { - // refreshToken, - // refreshTokenExpires: moment.utc() - // .add(Number(refresh_token_validity_ms), 'ms') - // .toDate() - // } - // }).exec(); + await addRefreshToken(user.id, refreshToken, refreshTokenExpiresAt); return resolve({ token, refreshToken }); } catch (err) { @@ -60,9 +53,11 @@ const createToken = function createToken (user) { }); }; -const invalidateToken = function invalidateToken ({ user, _jwt, _jwtString } = {}) { - createToken(user); - addTokenToBlacklist(_jwt, _jwtString); +const invalidateToken = async function invalidateToken ({ user, _jwt, _jwtString } = {}) { + // createToken(user); // TODO: why do we create a new token here?!?! + const hash = hashJWT(_jwtString); + await deleteRefreshToken(hash); + // addTokenToBlacklist(_jwt, _jwtString); }; const refreshJwt = async function refreshJwt (refreshToken) { @@ -105,8 +100,7 @@ const verifyJwt = function verifyJwt (req, res, next) { return next(new ForbiddenError(jwtInvalidErrorMessage)); } - User.findOne({ email: decodedJwt.sub.toLowerCase(), role: decodedJwt.role }) - .exec() + findUserByEmailAndRole({ email: decodedJwt.sub.toLowerCase(), role: decodedJwt.role }) .then(function (user) { if (!user) { throw new Error(); diff --git a/packages/api/lib/helpers/tokenBlacklist.js b/packages/api/lib/helpers/tokenBlacklist.js index de3c8526..24a7ee16 100644 --- a/packages/api/lib/helpers/tokenBlacklist.js +++ b/packages/api/lib/helpers/tokenBlacklist.js @@ -16,6 +16,7 @@ const cleanupExpiredTokens = function cleanupExpiredTokens () { } }; +// TODO: rework this function const isTokenBlacklisted = function isTokenBlacklisted (token, tokenString) { cleanupExpiredTokens(); diff --git a/packages/models/migrations/0003_add_cronjob_reset_password_expires.sql b/packages/models/migrations/0003_add_cronjob_reset_password_expires.sql new file mode 100644 index 00000000..6f702964 --- /dev/null +++ b/packages/models/migrations/0003_add_cronjob_reset_password_expires.sql @@ -0,0 +1 @@ +-- Custom SQL migration file, put you code below! -- \ No newline at end of file diff --git a/packages/models/migrations/0004_add_updateOn.sql b/packages/models/migrations/0004_add_updateOn.sql new file mode 100644 index 00000000..ee8f6c7b --- /dev/null +++ b/packages/models/migrations/0004_add_updateOn.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS "refresh_token" ( + "user_id" text NOT NULL, + "token" text, + "expires_at" timestamp, + CONSTRAINT "refresh_token_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +ALTER TABLE "profile" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "profile" ADD COLUMN "updated_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "refresh_token" ADD CONSTRAINT "refresh_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/models/migrations/0005_add_device_access_token.sql b/packages/models/migrations/0005_add_device_access_token.sql new file mode 100644 index 00000000..47dec71f --- /dev/null +++ b/packages/models/migrations/0005_add_device_access_token.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS "access_token" ( + "device_id" text NOT NULL, + "token" text +); +--> statement-breakpoint +ALTER TABLE "refresh_token" DROP CONSTRAINT "refresh_token_user_id_unique";--> statement-breakpoint +ALTER TABLE "password" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "password" ADD COLUMN "updated_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "access_token" ADD CONSTRAINT "access_token_device_id_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."device"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/models/migrations/0006_add_ref_sensor_device.sql b/packages/models/migrations/0006_add_ref_sensor_device.sql new file mode 100644 index 00000000..78463b56 --- /dev/null +++ b/packages/models/migrations/0006_add_ref_sensor_device.sql @@ -0,0 +1,5 @@ +DO $$ BEGIN + ALTER TABLE "sensor" ADD CONSTRAINT "sensor_device_id_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."device"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/models/migrations/meta/0003_snapshot.json b/packages/models/migrations/meta/0003_snapshot.json new file mode 100644 index 00000000..b4cfed0c --- /dev/null +++ b/packages/models/migrations/meta/0003_snapshot.json @@ -0,0 +1,568 @@ +{ + "id": "4bcd5795-2204-487c-89a0-3bf529da937e", + "prevId": "ea325078-5292-43a6-be9e-3b499c32eba5", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "columns": [ + "sensor_id", + "time" + ], + "nullsNotDistinct": false + } + } + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + } + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "columnsFrom": [ + "profile_id" + ], + "tableTo": "profile", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "HOME_V2_LORA" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/0004_snapshot.json b/packages/models/migrations/meta/0004_snapshot.json new file mode 100644 index 00000000..78353311 --- /dev/null +++ b/packages/models/migrations/meta/0004_snapshot.json @@ -0,0 +1,632 @@ +{ + "id": "e6acc6ef-9ec1-49c4-8244-a8525f405f59", + "prevId": "4bcd5795-2204-487c-89a0-3bf529da937e", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": { + "refresh_token_user_id_unique": { + "name": "refresh_token_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "HOME_V2_LORA" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/0005_snapshot.json b/packages/models/migrations/meta/0005_snapshot.json new file mode 100644 index 00000000..588f119f --- /dev/null +++ b/packages/models/migrations/meta/0005_snapshot.json @@ -0,0 +1,674 @@ +{ + "id": "dc99571c-be0a-48ad-9e11-dc7100fd7dbd", + "prevId": "e6acc6ef-9ec1-49c4-8244-a8525f405f59", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "HOME_V2_LORA" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/0006_snapshot.json b/packages/models/migrations/meta/0006_snapshot.json new file mode 100644 index 00000000..64c6059f --- /dev/null +++ b/packages/models/migrations/meta/0006_snapshot.json @@ -0,0 +1,688 @@ +{ + "id": "49122c3d-51f1-441a-b120-70a5b34d32d9", + "prevId": "dc99571c-be0a-48ad-9e11-dc7100fd7dbd", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "HOME_V2_LORA" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json index f5bfdad3..42e91e3f 100644 --- a/packages/models/migrations/meta/_journal.json +++ b/packages/models/migrations/meta/_journal.json @@ -22,6 +22,34 @@ "when": 1727299848109, "tag": "0002_add_password_reset_table", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1727300042343, + "tag": "0003_add_cronjob_reset_password_expires", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1727340849500, + "tag": "0004_add_updateOn", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1727429631691, + "tag": "0005_add_device_access_token", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1727430508941, + "tag": "0006_add_ref_sensor_device", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js index 1f51f505..90852213 100644 --- a/packages/models/schema/schema.js +++ b/packages/models/schema/schema.js @@ -27,7 +27,8 @@ const device = pgTable('device', { createdAt: timestamp('created_at').defaultNow() .notNull(), updatedAt: timestamp('updated_at').defaultNow() - .notNull(), + .notNull() + .$onUpdateFn(() => new Date()), latitude: doublePrecision('latitude').notNull(), longitude: doublePrecision('longitude').notNull(), userId: text('user_id').notNull(), @@ -46,8 +47,12 @@ const sensor = pgTable('sensor', { createdAt: timestamp('created_at').defaultNow() .notNull(), updatedAt: timestamp('updated_at').defaultNow() - .notNull(), - deviceId: text('device_id').notNull(), + .notNull() + .$onUpdateFn(() => new Date()), + deviceId: text('device_id').notNull() + .references(() => device.id, { + onDelete: 'cascade', + }), sensorWikiType: text('sensor_wiki_type'), sensorWikiPhenomenon: text('sensor_wiki_phenomenon'), sensorWikiUnit: text('sensor_wiki_unit'), @@ -70,6 +75,7 @@ const user = pgTable('user', { .notNull(), updatedAt: timestamp('updated_at').defaultNow() .notNull() + .$onUpdateFn(() => new Date()) }); const password = pgTable('password', { @@ -79,7 +85,12 @@ const password = pgTable('password', { onDelete: 'cascade', onUpdate: 'cascade' }) + .notNull(), + createdAt: timestamp('created_at').defaultNow() + .notNull(), + updatedAt: timestamp('updated_at').defaultNow() .notNull() + .$onUpdateFn(() => new Date()), }); const passwordReset = pgTable('password_reset', { @@ -106,7 +117,12 @@ const profile = pgTable('profile', { userId: text('user_id').references(() => user.id, { onDelete: 'cascade', onUpdate: 'cascade' - }) + }), + createdAt: timestamp('created_at').defaultNow() + .notNull(), + updatedAt: timestamp('updated_at').defaultNow() + .notNull() + .$onUpdateFn(() => new Date()), }); const profileImage = pgTable('profile_image', { @@ -120,18 +136,38 @@ const profileImage = pgTable('profile_image', { createdAt: timestamp('created_at').defaultNow() .notNull(), updatedAt: timestamp('updated_at').defaultNow() - .notNull(), + .notNull() + .$onUpdateFn(() => new Date()), profileId: text('profile_id').references(() => profile.id, { onDelete: 'cascade', onUpdate: 'cascade' }) }); +const refreshToken = pgTable('refresh_token', { + userId: text('user_id') + .notNull() + .references(() => user.id, { + onDelete: 'cascade', + }), + token: text('token'), + expiresAt: timestamp('expires_at') +}); + +const accessToken = pgTable('access_token', { + deviceId: text('device_id').notNull() + .references(() => device.id, { + onDelete: 'cascade' + }), + token: text('token'), +}); + /** * Relations */ -const deviceRelations = relations(device, ({ many }) => ({ - sensors: many(sensor) +const deviceRelations = relations(device, ({ many, one }) => ({ + sensors: many(sensor), + accessToken: one(accessToken) })); const sensorRelations = relations(sensor, ({ one }) => ({ @@ -154,7 +190,8 @@ const userRelations = relations(user, ({ one, many }) => ({ fields: [user.id], references: [profile.userId] }), - devices: many(device) + devices: many(device), + refreshToken: many(refreshToken) })); const profileRelations = relations(profile, ({ one }) => ({ @@ -168,6 +205,21 @@ const profileRelations = relations(profile, ({ one }) => ({ }) })); +const refreshTokenRelations = relations(refreshToken, ({ one }) => ({ + user: one(user, { + fields: [refreshToken.userId], + references: [user.id] + }) +})); + +const accessTokenRelations = relations(accessToken, ({ one }) => ({ + user: one(device, { + fields: [accessToken.deviceId], + references: [device.id] + }) +})); + +module.exports.accessTokenTable = accessToken; module.exports.deviceTable = device; module.exports.sensorTable = sensor; module.exports.userTable = user; @@ -175,7 +227,10 @@ module.exports.passwordTable = password; module.exports.passwordResetTable = passwordReset; module.exports.profileTable = profile; module.exports.profileImageTable = profileImage; +module.exports.refreshTokenTable = refreshToken; +module.exports.accessTokenRelations = accessTokenRelations; module.exports.deviceRelations = deviceRelations; module.exports.sensorRelations = sensorRelations; module.exports.userRelations = userRelations; module.exports.profileRelations = profileRelations; +module.exports.refreshTokenRelations = refreshTokenRelations; diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js new file mode 100644 index 00000000..e2b475cc --- /dev/null +++ b/packages/models/src/device/index.js @@ -0,0 +1,74 @@ +'use strict'; + +const crypto = require('crypto'); +const { deviceTable, sensorTable, accessTokenTable } = require('../../schema/schema'); +const sensorLayouts = require('../box/sensorLayouts'); +const { db } = require('../drizzle'); +const ModelError = require('../modelError'); + +const createDevice = async function createDevice (userId, params) { + const { model, sensorTemplates } = params; + let { sensors, useAuth } = params; + + // if model is not empty, get sensor definitions from products + // otherwise, sensors should not be empty + if (model && sensors) { + return Promise.reject(new ModelError('Parameters model and sensors cannot be specified at the same time.', { type: 'UnprocessableEntityError' })); + } else if (model && !sensors) { + if (sensorTemplates) { + const layout = sensorLayouts.getSensorsForModel(model); + sensors = []; + for (const sensor of layout) { + if (sensorTemplates.includes(sensor['sensorType'].toLowerCase())) { + sensors.push(sensor); + } + } + } else { + sensors = sensorLayouts.getSensorsForModel(model); + } + } + if (model) { + //activate useAuth only for certain models until all sketches are updated + if (['homeV2Lora', 'homeV2Ethernet', 'homeV2EthernetFeinstaub', 'homeV2Wifi', 'homeV2WifiFeinstaub', 'homeEthernet', 'homeWifi', 'homeEthernetFeinstaub', 'homeWifiFeinstaub', 'hackair_home_v2'].indexOf(model) !== -1) { + useAuth = true; + } else { + useAuth = false; + } + } + + const [device] = await db.insert(deviceTable).values({ + userId, + name: params.name, + exposure: params.exposure, + description: params.description, + latitude: params.location[1], + longitude: params.location[0], + useAuth, + model: 'HOME_V2_LORA' + }) + .returning(); + + const [accessToken] = await db.insert(accessTokenTable).values({ + deviceId: device.id, + token: crypto.randomBytes(32).toString('hex') + }) + .returning({ token: accessTokenTable.token }); + + // Iterate over sensors and add device id + sensors = sensors.map((sensor) => ({ + deviceId: device.id, + ...sensor + })); + + const deviceSensors = await db.insert(sensorTable).values(sensors) + .returning(); + + device['accessToken'] = accessToken.token; + device['sensors'] = deviceSensors; + + return device; +}; + +module.exports = { + createDevice +}; diff --git a/packages/models/src/drizzle.js b/packages/models/src/drizzle.js index 06bff050..2c61dd62 100644 --- a/packages/models/src/drizzle.js +++ b/packages/models/src/drizzle.js @@ -13,7 +13,9 @@ const { deviceRelations, sensorRelations, userRelations, - profileRelations + profileRelations, + accessTokenRelations, + accessTokenTable } = require('../schema/schema'); @@ -22,6 +24,7 @@ const pool = new Pool({ }); const schema = { + accessTokenTable, deviceTable, sensorTable, userTable, @@ -32,7 +35,8 @@ const schema = { deviceRelations, sensorRelations, userRelations, - profileRelations + profileRelations, + accessTokenRelations }; const db = drizzle(pool, { diff --git a/packages/models/src/token/refresh.js b/packages/models/src/token/refresh.js new file mode 100644 index 00000000..463df796 --- /dev/null +++ b/packages/models/src/token/refresh.js @@ -0,0 +1,22 @@ +'use strict'; + +const { eq } = require('drizzle-orm'); +const { refreshTokenTable } = require('../../schema/schema'); +const { db } = require('../drizzle'); + +const addRefreshToken = async function addRefreshToken (userId, token, expiresAt) { + await db.insert(refreshTokenTable).values({ + userId, + token, + expiresAt + }); +}; + +const deleteRefreshToken = async function deleteRefreshToken (hash) { + await db.delete(refreshTokenTable).where(eq(refreshTokenTable.token, hash)); +}; + +module.exports = { + addRefreshToken, + deleteRefreshToken +}; diff --git a/packages/models/src/user/user.js b/packages/models/src/user/user.js index 9e9615c5..a04414d7 100644 --- a/packages/models/src/user/user.js +++ b/packages/models/src/user/user.js @@ -28,6 +28,7 @@ const { mongoose } = require('../db'), const { createProfile } = require('../profile/profile'); const { userTable, passwordTable, passwordResetTable } = require('../../schema/schema'); +const { eq } = require('drizzle-orm'); const userNameRequirementsText = 'Parameter name must consist of at least 3 and up to 40 alphanumerics (a-zA-Z0-9), dot (.), dash (-), underscore (_) and spaces.', userEmailRequirementsText = 'Parameter {PATH} is not a valid email address.'; @@ -784,6 +785,48 @@ const initPasswordReset = async function initPasswordReset ({ email }) { } }); }; +const validatePassword = function validatePassword (newPassword) { + return newPassword.length >= Number(password_min_length); +}; + +const resetOldPassword = async function resetOldPassword ({ password, token }) { + const passwordReset = await db.query.passwordResetTable.findFirst({ + where: (reset, { eq }) => eq(reset.token, token) + }); + + if (!passwordReset) { + throw new ModelError('Password reset for this user not possible', { type: 'ForbiddenError' }); + } + + if (moment.utc().isAfter(moment.utc(passwordReset.expiresAt))) { + throw new ModelError('Password reset token expired', { type: 'ForbiddenError' }); + } + + // Validate new Password + if (validatePassword(password) === false) { + throw new ModelError(`Password must be at least ${password_min_length} characters.`); + } + + // Update reset password + const hashedPassword = await passwordHasher(password); + await db.update(passwordTable) + .set({ hash: hashedPassword }) + .where(eq(passwordTable.userId, passwordReset.userId)); + + // invalidate password reset token + await db.delete(passwordResetTable).where(eq(passwordResetTable.token, token)); + + // TODO: invalidate refreshToken and active accessTokens +}; + +const findUserByEmailAndRole = async function findUserByEmailAndRole ({ email, role }) { + const user = await db.query.userTable.findFirst({ + where: (user, { eq, and }) => and(eq(user.email, email.toLowerCase(), eq(user.role, role))) + }); + + return user; +}; + module.exports = { schema: userSchema, model: userModel, @@ -791,6 +834,8 @@ module.exports = { deleteUser, updateUser, findUserByNameOrEmail, + findUserByEmailAndRole, checkPassword, - initPasswordReset + initPasswordReset, + resetOldPassword }; From 8b5de20ab3612e5bf123611bdb5c5a47137600a0 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Fri, 27 Sep 2024 15:36:16 +0200 Subject: [PATCH 06/34] more refectoring --- .../api/lib/controllers/boxesController.js | 20 +- packages/api/lib/helpers/userParamHelpers.js | 5 +- .../migrations/0007_add_device_models.sql | 26 + .../models/migrations/meta/0007_snapshot.json | 713 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 7 + packages/models/schema/enum.js | 27 +- packages/models/src/device/index.js | 32 +- packages/models/src/password/index.js | 15 + packages/models/src/user/user.js | 40 +- 9 files changed, 865 insertions(+), 20 deletions(-) create mode 100644 packages/models/migrations/0007_add_device_models.sql create mode 100644 packages/models/migrations/meta/0007_snapshot.json create mode 100644 packages/models/src/password/index.js diff --git a/packages/api/lib/controllers/boxesController.js b/packages/api/lib/controllers/boxesController.js index cddfea93..9daf68d7 100644 --- a/packages/api/lib/controllers/boxesController.js +++ b/packages/api/lib/controllers/boxesController.js @@ -71,6 +71,8 @@ const handleError = require('../helpers/errorHandler'), jsonstringify = require('stringify-stream'); const { createDevice } = require('@sensebox/opensensemap-api-models/src/device'); +const { findByUserId } = require('@sensebox/opensensemap-api-models/src/password'); +const { removeDevice, checkPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); /** * @apiDefine Addons @@ -500,10 +502,8 @@ const getBox = async function getBox (req, res) { */ const postNewBox = async function postNewBox (req, res) { try { - // let newBox = await req.user.addBox(req._userParams); - // newBox = await Box.populate(newBox, Box.BOX_SUB_PROPS_FOR_POPULATION); - const newDevice = await createDevice(req.user.id, req._userParams); + // TODO: only return specific fields newBox = await Box.populate(newBox, Box.BOX_SUB_PROPS_FOR_POPULATION); res.send(201, { message: 'Box successfully created', data: newDevice }); clearCache(['getBoxes', 'getStats']); @@ -581,11 +581,14 @@ const deleteBox = async function deleteBox (req, res) { const { password, boxId } = req._userParams; try { - await req.user.checkPassword(password); - const box = await req.user.removeBox(boxId); - res.send({ code: 'Ok', message: 'box and all associated measurements marked for deletion' }); + const hashedPassword = await findByUserId(req.user.id); + + await checkPassword(password, hashedPassword); + const device = await removeDevice(boxId); + + res.send({ code: 'Ok', message: 'device and all associated measurements marked for deletion' }); clearCache(['getBoxes', 'getStats']); - postToMattermost(`Box deleted: ${req.user.name} (${redactEmail(req.user.email)}) just deleted "${box.name}" (${boxId})`); + postToMattermost(`Device deleted: ${req.user.name} (${redactEmail(req.user.email)}) just deleted "${device.name}" (${boxId})`); } catch (err) { return handleError(err); @@ -717,7 +720,8 @@ module.exports = { deleteBox: [ checkContentType, retrieveParameters([ - { predef: 'boxId', required: true }, + { name: 'boxId', required: true, dataType: 'String' }, + // { predef: 'boxId', required: true }, { predef: 'password' } ]), checkPrivilege, diff --git a/packages/api/lib/helpers/userParamHelpers.js b/packages/api/lib/helpers/userParamHelpers.js index 918a9b87..aed22880 100644 --- a/packages/api/lib/helpers/userParamHelpers.js +++ b/packages/api/lib/helpers/userParamHelpers.js @@ -1,5 +1,7 @@ 'use strict'; +const { checkDeviceOwner } = require('@sensebox/opensensemap-api-models/src/user/user'); + const { BadRequestError, UnprocessableEntityError, InvalidArgumentError, ForbiddenError } = require('restify-errors'), { utils: { parseAndValidateTimestamp }, db: { mongoose }, decoding: { validators: { transformAndValidateCoords } } } = require('@sensebox/opensensemap-api-models'), moment = require('moment'), @@ -548,7 +550,8 @@ const checkPrivilege = async function checkPrivilege (req) { if (req._userParams.boxId) { try { - req.user.checkBoxOwner(req._userParams.boxId); + await checkDeviceOwner(req.user.id, req._userParams.boxId); + // req.user.checkBoxOwner(req._userParams.boxId); return; } catch (err) { diff --git a/packages/models/migrations/0007_add_device_models.sql b/packages/models/migrations/0007_add_device_models.sql new file mode 100644 index 00000000..05f01b6f --- /dev/null +++ b/packages/models/migrations/0007_add_device_models.sql @@ -0,0 +1,26 @@ +ALTER TYPE "model" ADD VALUE 'home_v2_lora';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'home_v2_ethernet';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'home_v2_ethernet_feinstaub';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'home_v2_wifi';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'home_v2_wifi_feinstaub';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'home_ethernet';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'home_wifi';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'home_ethernet_feinstaub';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'home_wifi_feinstaub';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_sds011';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_sds011_dht11';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_sds011_dht22';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_sds011_bmp180';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_sds011_bme280';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_pms1003';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_pms1003_bme280';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_pms3003';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_pms3003_bme280';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_pms5003';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_pms5003_bme280';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_pms7003';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_pms7003_bme280';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_sps30_bme280';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten_sps30_sht3x';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'hackair_home_v2';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'custom'; \ No newline at end of file diff --git a/packages/models/migrations/meta/0007_snapshot.json b/packages/models/migrations/meta/0007_snapshot.json new file mode 100644 index 00000000..9f3e5e59 --- /dev/null +++ b/packages/models/migrations/meta/0007_snapshot.json @@ -0,0 +1,713 @@ +{ + "id": "a6df02d8-5c4a-4e6b-808a-722ec2e4187c", + "prevId": "49122c3d-51f1-441a-b120-70a5b34d32d9", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "home_v2_lora", + "home_v2_ethernet", + "home_v2_ethernet_feinstaub", + "home_v2_wifi", + "home_v2_wifi_feinstaub", + "home_ethernet", + "home_wifi", + "home_ethernet_feinstaub", + "home_wifi_feinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "luftdaten_pms1003", + "luftdaten_pms1003_bme280", + "luftdaten_pms3003", + "luftdaten_pms3003_bme280", + "luftdaten_pms5003", + "luftdaten_pms5003_bme280", + "luftdaten_pms7003", + "luftdaten_pms7003_bme280", + "luftdaten_sps30_bme280", + "luftdaten_sps30_sht3x", + "hackair_home_v2", + "custom" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json index 42e91e3f..912ff56c 100644 --- a/packages/models/migrations/meta/_journal.json +++ b/packages/models/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1727430508941, "tag": "0006_add_ref_sensor_device", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1727431399829, + "tag": "0007_add_device_models", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/models/schema/enum.js b/packages/models/schema/enum.js index 03978415..0b4fe53b 100644 --- a/packages/models/schema/enum.js +++ b/packages/models/schema/enum.js @@ -3,7 +3,32 @@ const { pgEnum } = require('drizzle-orm/pg-core'); const deviceModel = pgEnum('model', [ - 'HOME_V2_LORA' + 'home_v2_lora', + 'home_v2_ethernet', + 'home_v2_ethernet_feinstaub', + 'home_v2_wifi', + 'home_v2_wifi_feinstaub', + 'home_ethernet', + 'home_wifi', + 'home_ethernet_feinstaub', + 'home_wifi_feinstaub', + 'luftdaten_sds011', + 'luftdaten_sds011_dht11', + 'luftdaten_sds011_dht22', + 'luftdaten_sds011_bmp180', + 'luftdaten_sds011_bme280', + 'luftdaten_pms1003', + 'luftdaten_pms1003_bme280', + 'luftdaten_pms3003', + 'luftdaten_pms3003_bme280', + 'luftdaten_pms5003', + 'luftdaten_pms5003_bme280', + 'luftdaten_pms7003', + 'luftdaten_pms7003_bme280', + 'luftdaten_sps30_bme280', + 'luftdaten_sps30_sht3x', + 'hackair_home_v2', + 'custom', ]); const exposure = pgEnum('exposure', [ diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index e2b475cc..2b227886 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -7,7 +7,7 @@ const { db } = require('../drizzle'); const ModelError = require('../modelError'); const createDevice = async function createDevice (userId, params) { - const { model, sensorTemplates } = params; + const { name, exposure, description, location, model, sensorTemplates } = params; let { sensors, useAuth } = params; // if model is not empty, get sensor definitions from products @@ -36,15 +36,16 @@ const createDevice = async function createDevice (userId, params) { } } + // TODO: handle in transaction const [device] = await db.insert(deviceTable).values({ userId, - name: params.name, - exposure: params.exposure, - description: params.description, - latitude: params.location[1], - longitude: params.location[0], + name, + exposure, + description, + latitude: location[1], + longitude: location[0], useAuth, - model: 'HOME_V2_LORA' + model }) .returning(); @@ -69,6 +70,21 @@ const createDevice = async function createDevice (userId, params) { return device; }; +const deleteDevice = async function (filter) { + return await db.delete(deviceTable).where(filter) + .returning(); +}; + +const findById = async function findById (deviceId) { + const device = await db.query.deviceTable.findFirst({ + where: (device, { eq }) => eq(device.id, deviceId) + }); + + return device; +}; + module.exports = { - createDevice + createDevice, + deleteDevice, + findById }; diff --git a/packages/models/src/password/index.js b/packages/models/src/password/index.js new file mode 100644 index 00000000..df63fb98 --- /dev/null +++ b/packages/models/src/password/index.js @@ -0,0 +1,15 @@ +'use strict'; + +const { db } = require('../drizzle'); + +const findByUserId = async function findByUserId (userId) { + const password = await db.query.passwordTable.findFirst({ + where: (password, { eq }) => eq(password.userId, userId) + }); + + return password; +}; + +module.exports = { + findByUserId +}; diff --git a/packages/models/src/user/user.js b/packages/models/src/user/user.js index a04414d7..e957dff4 100644 --- a/packages/models/src/user/user.js +++ b/packages/models/src/user/user.js @@ -27,8 +27,9 @@ const { mongoose } = require('../db'), isemail = require('isemail'); const { createProfile } = require('../profile/profile'); -const { userTable, passwordTable, passwordResetTable } = require('../../schema/schema'); +const { userTable, passwordTable, passwordResetTable, deviceTable } = require('../../schema/schema'); const { eq } = require('drizzle-orm'); +const { findById, deleteDevice } = require('../device'); const userNameRequirementsText = 'Parameter name must consist of at least 3 and up to 40 alphanumerics (a-zA-Z0-9), dot (.), dash (-), underscore (_) and spaces.', userEmailRequirementsText = 'Parameter {PATH} is not a valid email address.'; @@ -827,6 +828,39 @@ const findUserByEmailAndRole = async function findUserByEmailAndRole ({ email, r return user; }; +const checkDeviceOwner = async function checkDeviceOwner (userId, deviceId) { + + const device = await findById(deviceId); + + if (!device || device.userId !== userId) { + throw new ModelError('User does not own this senseBox', { type: 'ForbiddenError' }); + } + + return true; +}; + +const removeDevice = async function removeDevice (deviceId) { + + const device = await findById(deviceId); + + if (!device) { + return Promise.reject(new ModelError('coudn\'t remove, device not found', { type: 'NotFoundError' })); + } + + // TODO: remove all measurements + // // remove box and measurements + // box.removeSelfAndMeasurements() + // .catch(function (err) { + // throw err; + // }); + + const [deletedDevice] = await deleteDevice(eq(device.id, deviceId)); + + return deletedDevice; +}; + + + module.exports = { schema: userSchema, model: userModel, @@ -837,5 +871,7 @@ module.exports = { findUserByEmailAndRole, checkPassword, initPasswordReset, - resetOldPassword + resetOldPassword, + removeDevice, + checkDeviceOwner }; From 80e81a6c5a40932deb9841b1590a6b1fc0e3daa5 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Fri, 27 Sep 2024 16:13:59 +0200 Subject: [PATCH 07/34] update stats --- .../lib/controllers/statisticsController.js | 21 +++++++++++-------- packages/models/src/stats/index.js | 16 ++++++++++++++ 2 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 packages/models/src/stats/index.js diff --git a/packages/api/lib/controllers/statisticsController.js b/packages/api/lib/controllers/statisticsController.js index a5e6e81e..8edce279 100644 --- a/packages/api/lib/controllers/statisticsController.js +++ b/packages/api/lib/controllers/statisticsController.js @@ -1,6 +1,8 @@ 'use strict'; -const { Box, Measurement } = require('@sensebox/opensensemap-api-models'), +const { count } = require('@sensebox/opensensemap-api-models/src/stats'); + +const { Box } = require('@sensebox/opensensemap-api-models'), { UnprocessableEntityError, BadRequestError } = require('restify-errors'), idwTransformer = require('../transformers/idwTransformer'), { addCache, createDownloadFilename, computeTimestampTruncationLength, csvStringifier } = require('../helpers/apiUtils'), @@ -28,14 +30,15 @@ const getStatistics = async function getStatistics (req, res) { const { human } = req._userParams; try { let results = await Promise.all([ - Box.count({}), - Measurement.count({}), - Measurement.count({ - createdAt: { - '$gt': new Date(Date.now() - 60000), - '$lt': new Date() - } - }) + count('device'), + count('sensor'), + count('measurement'), + // Measurement.count({ + // createdAt: { + // '$gt': new Date(Date.now() - 60000), + // '$lt': new Date() + // } + // }) ]); if (human === 'true') { results = results.map(r => millify.default(r).toString()); diff --git a/packages/models/src/stats/index.js b/packages/models/src/stats/index.js new file mode 100644 index 00000000..74220dd8 --- /dev/null +++ b/packages/models/src/stats/index.js @@ -0,0 +1,16 @@ +'use strict'; + +const { sql } = require('drizzle-orm'); +const { db } = require('../drizzle'); + +const count = async function count (table) { + const { rows } = await db.execute(sql`SELECT * FROM approximate_row_count(${table});`); + + const [count] = rows; + + return count.approximate_row_count; +}; + +module.exports = { + count +}; From ef148013aa051c5a4cc9a94f8fa8b427225eb781 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Mon, 30 Sep 2024 12:05:26 +0200 Subject: [PATCH 08/34] activate postgis --- .../api/lib/controllers/boxesController.js | 16 +- .../lib/controllers/statisticsController.js | 9 +- packages/api/lib/helpers/userParamHelpers.js | 5 +- packages/api/package.json | 1 + .../models/migrations/0008_init_postgis.sql | 2 + .../migrations/0009_add_geometry_column.sql | 2 + .../models/migrations/meta/0008_snapshot.json | 713 +++++++++++++++++ .../models/migrations/meta/0009_snapshot.json | 735 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 14 + packages/models/schema/schema.js | 7 +- packages/models/src/box/box.js | 64 +- packages/models/src/device/index.js | 1 + packages/models/src/stats/index.js | 7 +- 13 files changed, 1554 insertions(+), 22 deletions(-) create mode 100644 packages/models/migrations/0008_init_postgis.sql create mode 100644 packages/models/migrations/0009_add_geometry_column.sql create mode 100644 packages/models/migrations/meta/0008_snapshot.json create mode 100644 packages/models/migrations/meta/0009_snapshot.json diff --git a/packages/api/lib/controllers/boxesController.js b/packages/api/lib/controllers/boxesController.js index 9daf68d7..e6a6c3e0 100644 --- a/packages/api/lib/controllers/boxesController.js +++ b/packages/api/lib/controllers/boxesController.js @@ -70,6 +70,7 @@ const } = require('../helpers/userParamHelpers'), handleError = require('../helpers/errorHandler'), jsonstringify = require('stringify-stream'); +const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/box'); const { createDevice } = require('@sensebox/opensensemap-api-models/src/device'); const { findByUserId } = require('@sensebox/opensensemap-api-models/src/password'); const { removeDevice, checkPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); @@ -454,16 +455,14 @@ const getBox = async function getBox (req, res) { const { format, boxId } = req._userParams; try { - const box = await Box.findBoxById(boxId); + const device = await findDeviceById(boxId); - if (format === 'geojson') { - const coordinates = box.currentLocation.coordinates; - box.currentLocation = undefined; - box.loc = undefined; + if (format === 'geojson') { // Handle with PostGIS Extension + const coordinates = [device.longitude, device.latitude]; - return res.send(point(coordinates, box)); + return res.send(point(coordinates, device)); } - res.send(box); + res.send(device); } catch (err) { return handleError(err); } @@ -720,8 +719,7 @@ module.exports = { deleteBox: [ checkContentType, retrieveParameters([ - { name: 'boxId', required: true, dataType: 'String' }, - // { predef: 'boxId', required: true }, + { predef: 'boxId', required: true }, { predef: 'password' } ]), checkPrivilege, diff --git a/packages/api/lib/controllers/statisticsController.js b/packages/api/lib/controllers/statisticsController.js index 8edce279..8161b862 100644 --- a/packages/api/lib/controllers/statisticsController.js +++ b/packages/api/lib/controllers/statisticsController.js @@ -1,6 +1,6 @@ 'use strict'; -const { count } = require('@sensebox/opensensemap-api-models/src/stats'); +const { count, countTimeBucket } = require('@sensebox/opensensemap-api-models/src/stats'); const { Box } = require('@sensebox/opensensemap-api-models'), { UnprocessableEntityError, BadRequestError } = require('restify-errors'), @@ -33,12 +33,7 @@ const getStatistics = async function getStatistics (req, res) { count('device'), count('sensor'), count('measurement'), - // Measurement.count({ - // createdAt: { - // '$gt': new Date(Date.now() - 60000), - // '$lt': new Date() - // } - // }) + countTimeBucket('measurement', '1 minute') ]); if (human === 'true') { results = results.map(r => millify.default(r).toString()); diff --git a/packages/api/lib/helpers/userParamHelpers.js b/packages/api/lib/helpers/userParamHelpers.js index aed22880..eff372fd 100644 --- a/packages/api/lib/helpers/userParamHelpers.js +++ b/packages/api/lib/helpers/userParamHelpers.js @@ -1,9 +1,10 @@ 'use strict'; +const { isCuid } = require('@paralleldrive/cuid2'); const { checkDeviceOwner } = require('@sensebox/opensensemap-api-models/src/user/user'); const { BadRequestError, UnprocessableEntityError, InvalidArgumentError, ForbiddenError } = require('restify-errors'), - { utils: { parseAndValidateTimestamp }, db: { mongoose }, decoding: { validators: { transformAndValidateCoords } } } = require('@sensebox/opensensemap-api-models'), + { utils: { parseAndValidateTimestamp }, decoding: { validators: { transformAndValidateCoords } } } = require('@sensebox/opensensemap-api-models'), moment = require('moment'), isemail = require('isemail'), handleModelError = require('./errorHandler'), @@ -67,7 +68,7 @@ const stringParser = function stringParser (s) { */ const idCheck = function idCheck (id) { - if (mongoose.Types.ObjectId.isValid(id) && id !== '00112233445566778899aabb') { + if (isCuid(id) && id !== '00112233445566778899aabb') { return id; } diff --git a/packages/api/package.json b/packages/api/package.json index e8f6d287..3b0c561b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -12,6 +12,7 @@ "Felix Erdmann" ], "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", "@sensebox/opensensemap-api-models": "3.3.0", "@turf/area": "^6.5.0", "@turf/bbox": "^6.5.0", diff --git a/packages/models/migrations/0008_init_postgis.sql b/packages/models/migrations/0008_init_postgis.sql new file mode 100644 index 00000000..3972441f --- /dev/null +++ b/packages/models/migrations/0008_init_postgis.sql @@ -0,0 +1,2 @@ +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS postgis CASCADE; \ No newline at end of file diff --git a/packages/models/migrations/0009_add_geometry_column.sql b/packages/models/migrations/0009_add_geometry_column.sql new file mode 100644 index 00000000..61af1899 --- /dev/null +++ b/packages/models/migrations/0009_add_geometry_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE "device" ADD COLUMN "location" geometry(point) NOT NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "spatial_index" ON "device" USING gist ("location"); \ No newline at end of file diff --git a/packages/models/migrations/meta/0008_snapshot.json b/packages/models/migrations/meta/0008_snapshot.json new file mode 100644 index 00000000..f633b417 --- /dev/null +++ b/packages/models/migrations/meta/0008_snapshot.json @@ -0,0 +1,713 @@ +{ + "id": "bfaceeec-8e34-4365-9a07-4ba86dc195be", + "prevId": "a6df02d8-5c4a-4e6b-808a-722ec2e4187c", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "columns": [ + "sensor_id", + "time" + ], + "nullsNotDistinct": false + } + } + }, + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "columnsFrom": [ + "device_id" + ], + "tableTo": "device", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "columnsFrom": [ + "device_id" + ], + "tableTo": "device", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + } + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "columnsFrom": [ + "profile_id" + ], + "tableTo": "profile", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "home_v2_lora", + "home_v2_ethernet", + "home_v2_ethernet_feinstaub", + "home_v2_wifi", + "home_v2_wifi_feinstaub", + "home_ethernet", + "home_wifi", + "home_ethernet_feinstaub", + "home_wifi_feinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "luftdaten_pms1003", + "luftdaten_pms1003_bme280", + "luftdaten_pms3003", + "luftdaten_pms3003_bme280", + "luftdaten_pms5003", + "luftdaten_pms5003_bme280", + "luftdaten_pms7003", + "luftdaten_pms7003_bme280", + "luftdaten_sps30_bme280", + "luftdaten_sps30_sht3x", + "hackair_home_v2", + "custom" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/0009_snapshot.json b/packages/models/migrations/meta/0009_snapshot.json new file mode 100644 index 00000000..4db01b49 --- /dev/null +++ b/packages/models/migrations/meta/0009_snapshot.json @@ -0,0 +1,735 @@ +{ + "id": "a35bf829-bf84-4113-97f9-00b189b74561", + "prevId": "bfaceeec-8e34-4365-9a07-4ba86dc195be", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spatial_index": { + "name": "spatial_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "home_v2_lora", + "home_v2_ethernet", + "home_v2_ethernet_feinstaub", + "home_v2_wifi", + "home_v2_wifi_feinstaub", + "home_ethernet", + "home_wifi", + "home_ethernet_feinstaub", + "home_wifi_feinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "luftdaten_pms1003", + "luftdaten_pms1003_bme280", + "luftdaten_pms3003", + "luftdaten_pms3003_bme280", + "luftdaten_pms5003", + "luftdaten_pms5003_bme280", + "luftdaten_pms7003", + "luftdaten_pms7003_bme280", + "luftdaten_sps30_bme280", + "luftdaten_sps30_sht3x", + "hackair_home_v2", + "custom" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json index 912ff56c..a89ba57b 100644 --- a/packages/models/migrations/meta/_journal.json +++ b/packages/models/migrations/meta/_journal.json @@ -57,6 +57,20 @@ "when": 1727431399829, "tag": "0007_add_device_models", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1727688805009, + "tag": "0008_init_postgis", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1727689770873, + "tag": "0009_add_geometry_column", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js index 90852213..0b37c220 100644 --- a/packages/models/schema/schema.js +++ b/packages/models/schema/schema.js @@ -1,6 +1,6 @@ 'use strict'; -const { pgTable, text, boolean, timestamp, doublePrecision, json } = require('drizzle-orm/pg-core'); +const { pgTable, text, boolean, timestamp, doublePrecision, json, geometry, index } = require('drizzle-orm/pg-core'); const { relations } = require('drizzle-orm'); const { createId } = require('@paralleldrive/cuid2'); const { v4: uuidv4 } = require('uuid'); @@ -31,9 +31,12 @@ const device = pgTable('device', { .$onUpdateFn(() => new Date()), latitude: doublePrecision('latitude').notNull(), longitude: doublePrecision('longitude').notNull(), + location: geometry('location', { type: 'point', mode: 'xy', srid: 4326 }).notNull(), userId: text('user_id').notNull(), sensorWikiModel: text('sensor_wiki_model'), -}); +}, (t) => ({ + spatialIndex: index('spatial_index').using('gist', t.location), +}),); const sensor = pgTable('sensor', { id: text('id') diff --git a/packages/models/src/box/box.js b/packages/models/src/box/box.js index a29100bd..b57b593d 100644 --- a/packages/models/src/box/box.js +++ b/packages/models/src/box/box.js @@ -1,5 +1,7 @@ 'use strict'; +const { db } = require('../drizzle'); + const { mongoose } = require('../db'), timestamp = require('mongoose-timestamp'), Schema = mongoose.Schema, @@ -1137,7 +1139,67 @@ boxModel.BOX_VALID_MODELS = sensorLayouts.models; boxModel.BOX_VALID_ADDONS = sensorLayouts.addons; boxModel.BOX_VALID_EXPOSURES = ['unknown', 'indoor', 'outdoor', 'mobile']; +// let fullBox = populate; +// if (populate) { +// Object.assign(projection, BOX_PROPS_FOR_POPULATION); +// } +// if (includeSecrets) { +// projection.integrations = 1; +// projection.access_token = 1; +// } +// if (onlyLastMeasurements) { +// projection = { +// sensors: 1 +// }; +// fullBox = false; +// } +// if (onlyLocations) { +// projection = { +// locations: 1 +// }; +// fullBox = false; +// } + +// let findPromise = this.findById(id, projection); + +// if (fullBox === true || onlyLastMeasurements === true || Object.prototype.hasOwnProperty.call(projection, 'sensors')) { +// findPromise = findPromise +// .populate(BOX_SUB_PROPS_FOR_POPULATION); +// } + +// if (lean === true) { +// findPromise = findPromise +// .lean(); +// } + +// return findPromise +// .then(function (box) { +// if (!box) { +// throw new ModelError('Box not found', { type: 'NotFoundError' }); +// } + +// if (fullBox === true) { +// // fill in box.loc manually, as toJSON & virtuals are not supported in lean queries. +// box.loc = [{ geometry: box.currentLocation, type: 'Feature' }]; +// } + +// return box; +// }); + +const findDeviceById = async function findDeviceById (deviceId, { populate = true, includeSecrets = false, onlyLastMeasurements = false, onlyLocations = false, projection = {} } = {}) { + const device = await db.query.deviceTable.findFirst({ + where: (device, { eq }) => eq(device.id, deviceId) + }); + + if (!device) { + throw new ModelError('Device not found', { type: 'NotFoundError' }); + } + + return device; +}; + module.exports = { schema: boxSchema, - model: boxModel + model: boxModel, + findDeviceById }; diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index 2b227886..ef819e60 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -44,6 +44,7 @@ const createDevice = async function createDevice (userId, params) { description, latitude: location[1], longitude: location[0], + location: { x: location[1], y: location[0] }, useAuth, model }) diff --git a/packages/models/src/stats/index.js b/packages/models/src/stats/index.js index 74220dd8..8616fff3 100644 --- a/packages/models/src/stats/index.js +++ b/packages/models/src/stats/index.js @@ -11,6 +11,11 @@ const count = async function count (table) { return count.approximate_row_count; }; +const countTimeBucket = async function countTimeBucket (table, interval) { + // TODO: implement +}; + module.exports = { - count + count, + countTimeBucket }; From 5955e6bdf75384d9bd7965b68fb48c30ef3b8317 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Mon, 30 Sep 2024 14:52:00 +0200 Subject: [PATCH 09/34] start building getDevices --- .../api/lib/controllers/boxesController.js | 49 ++++++------- packages/models/src/box/box.js | 68 ++++++------------- packages/models/src/device/index.js | 59 +++++++++++++++- 3 files changed, 104 insertions(+), 72 deletions(-) diff --git a/packages/api/lib/controllers/boxesController.js b/packages/api/lib/controllers/boxesController.js index e6a6c3e0..373ba723 100644 --- a/packages/api/lib/controllers/boxesController.js +++ b/packages/api/lib/controllers/boxesController.js @@ -71,7 +71,7 @@ const handleError = require('../helpers/errorHandler'), jsonstringify = require('stringify-stream'); const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/box'); -const { createDevice } = require('@sensebox/opensensemap-api-models/src/device'); +const { createDevice, findDevices, findDevicesMinimal } = require('@sensebox/opensensemap-api-models/src/device'); const { findByUserId } = require('@sensebox/opensensemap-api-models/src/password'); const { removeDevice, checkPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); @@ -224,7 +224,7 @@ const geoJsonStringifyReplacer = function geoJsonStringifyReplacer (key, box) { * @apiParam {String=json,geojson} [format=json] the format the sensor data is returned in. * @apiParam {String} [grouptag] only return boxes with this grouptag, allows to specify multiple separated with a comma * @apiParam {String="homeEthernet","homeWifi","homeEthernetFeinstaub","homeWifiFeinstaub","luftdaten_sds011","luftdaten_sds011_dht11","luftdaten_sds011_dht22","luftdaten_sds011_bmp180","luftdaten_sds011_bme280"} [model] only return boxes with this model, allows to specify multiple separated with a comma - * @apiParam {Boolean="true","false"} [classify=false] if specified, the api will classify the boxes accordingly to their last measurements. + * @apiParam @deprecated {Boolean="true","false"} [classify=false] if specified, the api will classify the boxes accordingly to their last measurements. * @apiParam {Boolean="true","false"} [minimal=false] if specified, the api will only return a minimal set of box metadata consisting of [_id, updatedAt, currentLocation, exposure, name] for a fast response. * @apiParam {Boolean="true","false"} [full=false] if true the API will return populated lastMeasurements (use this with caution for now, expensive on the database) * @apiParam {Number} [near] A comma separated coordinate, if specified, the api will only return senseBoxes within maxDistance (in m) of this location @@ -313,34 +313,37 @@ const getBoxes = async function getBoxes (req, res) { } try { - let stream; + let devices; // Search boxes by name // Directly return results and do nothing else if (req._userParams.name) { - stream = await Box.findBoxes(req._userParams); + // stream = await Box.findBoxes(req._userParams); + devices = await findDevices(req._userParams, { id: true, name: true, location: true }); + } else if (req._userParams.minimal === 'true') { + devices = await findDevicesMinimal(req._userParams, { id: true, name: true, exposure: true, location: true, status: true }); + // stream = await Box.findBoxesMinimal(req._userParams); } else { - if (req._userParams.minimal === 'true') { - stream = await Box.findBoxesMinimal(req._userParams); - } else { - stream = await Box.findBoxesLastMeasurements(req._userParams); - } - - if (req._userParams.classify === 'true') { - stream = stream - .pipe(new classifyTransformer()) - .on('error', function (err) { - res.end(`Error: ${err.message}`); - }); - } + // stream = await Box.findBoxesLastMeasurements(req._userParams); } - stream - .pipe(stringifier) - .on('error', function (err) { - res.end(`Error: ${err.message}`); - }) - .pipe(res); + // Deprecated: classify is performed by database + // if (req._userParams.classify === 'true') { + // stream = stream + // .pipe(new classifyTransformer()) + // .on('error', function (err) { + // res.end(`Error: ${err.message}`); + // }); + // } + // } + + // stream + // .pipe(stringifier) + // .on('error', function (err) { + // res.end(`Error: ${err.message}`); + // }) + // .pipe(res); + res.send(devices); } catch (err) { return handleError(err); } diff --git a/packages/models/src/box/box.js b/packages/models/src/box/box.js index b57b593d..a2f3455d 100644 --- a/packages/models/src/box/box.js +++ b/packages/models/src/box/box.js @@ -1,5 +1,6 @@ 'use strict'; +const { exposure } = require('../../schema/enum'); const { db } = require('../drizzle'); const { mongoose } = require('../db'), @@ -170,6 +171,20 @@ const BOX_PROPS_FOR_POPULATION = { lastMeasurementAt: 1 }; +const DEVICE_COLUMNS_FOR_RETURNING = { + id: true, + name: true, + exposure: true, + model: true, + description: true, + image: true, + link: true, + createdAt: true, + updatedAt: true, + location: true, + status: true +}; + const BOX_SUB_PROPS_FOR_POPULATION = [ { path: 'sensors.lastMeasurement', select: { value: 1, createdAt: 1, _id: 0 } @@ -1139,56 +1154,13 @@ boxModel.BOX_VALID_MODELS = sensorLayouts.models; boxModel.BOX_VALID_ADDONS = sensorLayouts.addons; boxModel.BOX_VALID_EXPOSURES = ['unknown', 'indoor', 'outdoor', 'mobile']; -// let fullBox = populate; -// if (populate) { -// Object.assign(projection, BOX_PROPS_FOR_POPULATION); -// } -// if (includeSecrets) { -// projection.integrations = 1; -// projection.access_token = 1; -// } -// if (onlyLastMeasurements) { -// projection = { -// sensors: 1 -// }; -// fullBox = false; -// } -// if (onlyLocations) { -// projection = { -// locations: 1 -// }; -// fullBox = false; -// } - -// let findPromise = this.findById(id, projection); - -// if (fullBox === true || onlyLastMeasurements === true || Object.prototype.hasOwnProperty.call(projection, 'sensors')) { -// findPromise = findPromise -// .populate(BOX_SUB_PROPS_FOR_POPULATION); -// } - -// if (lean === true) { -// findPromise = findPromise -// .lean(); -// } - -// return findPromise -// .then(function (box) { -// if (!box) { -// throw new ModelError('Box not found', { type: 'NotFoundError' }); -// } - -// if (fullBox === true) { -// // fill in box.loc manually, as toJSON & virtuals are not supported in lean queries. -// box.loc = [{ geometry: box.currentLocation, type: 'Feature' }]; -// } - -// return box; -// }); - const findDeviceById = async function findDeviceById (deviceId, { populate = true, includeSecrets = false, onlyLastMeasurements = false, onlyLocations = false, projection = {} } = {}) { const device = await db.query.deviceTable.findFirst({ - where: (device, { eq }) => eq(device.id, deviceId) + columns: DEVICE_COLUMNS_FOR_RETURNING, + where: (device, { eq }) => eq(device.id, deviceId), + with: { + sensors: true + } }); if (!device) { diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index ef819e60..b5ce6e6e 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -5,6 +5,39 @@ const { deviceTable, sensorTable, accessTokenTable } = require('../../schema/sch const sensorLayouts = require('../box/sensorLayouts'); const { db } = require('../drizzle'); const ModelError = require('../modelError'); +const { inArray } = require('drizzle-orm'); + +const buildWhereClause = function buildWhereClause (opts = {}) { + const { phenomenon, fromDate, toDate, bbox, near, maxDistance, grouptag } = opts; + const clause = []; + + // simple string parameters + for (const param of ['exposure', 'model']) { + if (opts[param]) { + clause.push(inArray(deviceTable[param], opts[param])); + } + } + + if (grouptag) { + // TODO: implement + } + + if (bbox) { + // TODO: checkout postgis bbox queries + } + + if (near) { + // TODO: implement + } + + if (fromDate || toDate) { + if (phenomenon) { + // TODO: implement + } + } + + return clause; +}; const createDevice = async function createDevice (userId, params) { const { name, exposure, description, location, model, sensorTemplates } = params; @@ -84,8 +117,32 @@ const findById = async function findById (deviceId) { return device; }; +const findDevices = async function findDevices (opts = {}, columns = {}) { + const { name, limit } = opts; + const devices = await db.query.deviceTable.findMany({ + ...(Object.keys(columns).length !== 0 && { columns }), + where: (device, { ilike }) => ilike(device.name, `%${name}%`), + limit + }); + + return devices; +}; + +// TODO: merge with findDevices +const findDevicesMinimal = async function findDevicesMinimal (opts = {}, columns = {}) { + const whereClause = buildWhereClause(opts); + const devices = await db.query.deviceTable.findMany({ + ...(Object.keys(columns).length !== 0 && { columns }), + ...(Object.keys(whereClause).length !== 0 && { where: (_, { and }) => and(...whereClause) }) + }); + + return devices; +}; + module.exports = { createDevice, deleteDevice, - findById + findById, + findDevices, + findDevicesMinimal }; From ad564955c0076735292d981c0106624973a2a11e Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Mon, 30 Sep 2024 15:20:25 +0200 Subject: [PATCH 10/34] add device tags --- .../migrations/0010_add_device_tags.sql | 1 + .../models/migrations/meta/0010_snapshot.json | 742 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 7 + packages/models/schema/schema.js | 4 +- packages/models/src/device/index.js | 10 +- 5 files changed, 759 insertions(+), 5 deletions(-) create mode 100644 packages/models/migrations/0010_add_device_tags.sql create mode 100644 packages/models/migrations/meta/0010_snapshot.json diff --git a/packages/models/migrations/0010_add_device_tags.sql b/packages/models/migrations/0010_add_device_tags.sql new file mode 100644 index 00000000..fbd60138 --- /dev/null +++ b/packages/models/migrations/0010_add_device_tags.sql @@ -0,0 +1 @@ +ALTER TABLE "device" ADD COLUMN "tags" text[] DEFAULT ARRAY[]::text[]; \ No newline at end of file diff --git a/packages/models/migrations/meta/0010_snapshot.json b/packages/models/migrations/meta/0010_snapshot.json new file mode 100644 index 00000000..6b1bc123 --- /dev/null +++ b/packages/models/migrations/meta/0010_snapshot.json @@ -0,0 +1,742 @@ +{ + "id": "f4cdd9e0-a1cf-4a53-af88-646898760135", + "prevId": "a35bf829-bf84-4113-97f9-00b189b74561", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spatial_index": { + "name": "spatial_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "home_v2_lora", + "home_v2_ethernet", + "home_v2_ethernet_feinstaub", + "home_v2_wifi", + "home_v2_wifi_feinstaub", + "home_ethernet", + "home_wifi", + "home_ethernet_feinstaub", + "home_wifi_feinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "luftdaten_pms1003", + "luftdaten_pms1003_bme280", + "luftdaten_pms3003", + "luftdaten_pms3003_bme280", + "luftdaten_pms5003", + "luftdaten_pms5003_bme280", + "luftdaten_pms7003", + "luftdaten_pms7003_bme280", + "luftdaten_sps30_bme280", + "luftdaten_sps30_sht3x", + "hackair_home_v2", + "custom" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json index a89ba57b..65642189 100644 --- a/packages/models/migrations/meta/_journal.json +++ b/packages/models/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1727689770873, "tag": "0009_add_geometry_column", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1727701542050, + "tag": "0010_add_device_tags", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js index 0b37c220..284c41f8 100644 --- a/packages/models/schema/schema.js +++ b/packages/models/schema/schema.js @@ -1,7 +1,7 @@ 'use strict'; const { pgTable, text, boolean, timestamp, doublePrecision, json, geometry, index } = require('drizzle-orm/pg-core'); -const { relations } = require('drizzle-orm'); +const { relations, sql } = require('drizzle-orm'); const { createId } = require('@paralleldrive/cuid2'); const { v4: uuidv4 } = require('uuid'); const moment = require('moment'); @@ -32,6 +32,8 @@ const device = pgTable('device', { latitude: doublePrecision('latitude').notNull(), longitude: doublePrecision('longitude').notNull(), location: geometry('location', { type: 'point', mode: 'xy', srid: 4326 }).notNull(), + tags: text('tags').array() + .default(sql`ARRAY[]::text[]`), userId: text('user_id').notNull(), sensorWikiModel: text('sensor_wiki_model'), }, (t) => ({ diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index b5ce6e6e..6dd8b9f6 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -5,7 +5,7 @@ const { deviceTable, sensorTable, accessTokenTable } = require('../../schema/sch const sensorLayouts = require('../box/sensorLayouts'); const { db } = require('../drizzle'); const ModelError = require('../modelError'); -const { inArray } = require('drizzle-orm'); +const { inArray, arrayContains } = require('drizzle-orm'); const buildWhereClause = function buildWhereClause (opts = {}) { const { phenomenon, fromDate, toDate, bbox, near, maxDistance, grouptag } = opts; @@ -19,9 +19,10 @@ const buildWhereClause = function buildWhereClause (opts = {}) { } if (grouptag) { - // TODO: implement + clause.push(arrayContains(deviceTable['tags'], opts['grouptag'])); } + // https://orm.drizzle.team/learn/guides/postgis-geometry-point if (bbox) { // TODO: checkout postgis bbox queries } @@ -40,7 +41,7 @@ const buildWhereClause = function buildWhereClause (opts = {}) { }; const createDevice = async function createDevice (userId, params) { - const { name, exposure, description, location, model, sensorTemplates } = params; + const { name, exposure, description, location, model, grouptag, sensorTemplates } = params; let { sensors, useAuth } = params; // if model is not empty, get sensor definitions from products @@ -79,7 +80,8 @@ const createDevice = async function createDevice (userId, params) { longitude: location[0], location: { x: location[1], y: location[0] }, useAuth, - model + model, + tags: grouptag }) .returning(); From b7e0cd25be123b5d141f7fceef24755c3d397d49 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Tue, 1 Oct 2024 10:51:13 +0200 Subject: [PATCH 11/34] move stuff around --- .../api/lib/controllers/boxesController.js | 9 +++++---- .../lib/controllers/statisticsController.js | 10 +++++----- packages/models/schema/measurement.js | 15 +-------------- packages/models/schema/schema.js | 12 +++++++++++- packages/models/src/device/index.js | 11 +++++++++-- packages/models/src/drizzle.js | 4 +++- packages/models/src/stats/index.js | 19 ++++++++++++------- 7 files changed, 46 insertions(+), 34 deletions(-) diff --git a/packages/api/lib/controllers/boxesController.js b/packages/api/lib/controllers/boxesController.js index 373ba723..ad2fff23 100644 --- a/packages/api/lib/controllers/boxesController.js +++ b/packages/api/lib/controllers/boxesController.js @@ -71,7 +71,7 @@ const handleError = require('../helpers/errorHandler'), jsonstringify = require('stringify-stream'); const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/box'); -const { createDevice, findDevices, findDevicesMinimal } = require('@sensebox/opensensemap-api-models/src/device'); +const { createDevice, findDevices, findDevicesMinimal, findTags } = require('@sensebox/opensensemap-api-models/src/device'); const { findByUserId } = require('@sensebox/opensensemap-api-models/src/password'); const { removeDevice, checkPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); @@ -707,10 +707,11 @@ const claimBox = async function claimBox (req, res) { const getAllTags = async function getAllTags (req, res) { try { - const grouptags = await Box.find().distinct('grouptag') - .exec(); + const tags = await findTags(); + // const grouptags = await Box.find().distinct('grouptag') + // .exec(); - res.send({ code: 'Ok', data: grouptags }); + res.send({ code: 'Ok', data: tags }); } catch (err) { return handleError(err); } diff --git a/packages/api/lib/controllers/statisticsController.js b/packages/api/lib/controllers/statisticsController.js index 8161b862..270699ba 100644 --- a/packages/api/lib/controllers/statisticsController.js +++ b/packages/api/lib/controllers/statisticsController.js @@ -1,6 +1,7 @@ 'use strict'; -const { count, countTimeBucket } = require('@sensebox/opensensemap-api-models/src/stats'); +const { measurementTable } = require('@sensebox/opensensemap-api-models/schema/schema'); +const { rowCount, rowCountTimeBucket } = require('@sensebox/opensensemap-api-models/src/stats'); const { Box } = require('@sensebox/opensensemap-api-models'), { UnprocessableEntityError, BadRequestError } = require('restify-errors'), @@ -30,10 +31,9 @@ const getStatistics = async function getStatistics (req, res) { const { human } = req._userParams; try { let results = await Promise.all([ - count('device'), - count('sensor'), - count('measurement'), - countTimeBucket('measurement', '1 minute') + rowCount('device'), + rowCount('sensor'), + rowCountTimeBucket(measurementTable, 'time', 60000) ]); if (human === 'true') { results = results.map(r => millify.default(r).toString()); diff --git a/packages/models/schema/measurement.js b/packages/models/schema/measurement.js index e98c6c67..4ef5169a 100644 --- a/packages/models/schema/measurement.js +++ b/packages/models/schema/measurement.js @@ -1,18 +1,6 @@ 'use strict'; -const { pgTable, text, timestamp, doublePrecision, unique, pgMaterializedView, integer } = require('drizzle-orm/pg-core'); - -/** - * Table definition - */ -const measurement = pgTable('measurement', { - sensorId: text('sensor_id').notNull(), - time: timestamp('time', { precision: 3, withTimezone: true }).defaultNow() - .notNull(), - value: doublePrecision('value') -}, (t) => ({ - unq: unique().on(t.sensorId, t.time) -})); +const { text, timestamp, doublePrecision, pgMaterializedView, integer } = require('drizzle-orm/pg-core'); /** * Views @@ -64,7 +52,6 @@ const measurements1yearView = pgMaterializedView('measurement_1year', { module.exports = { - table: measurement, views: { measurement10minView, measurements1hourView, diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js index 284c41f8..31e57dd7 100644 --- a/packages/models/schema/schema.js +++ b/packages/models/schema/schema.js @@ -1,6 +1,6 @@ 'use strict'; -const { pgTable, text, boolean, timestamp, doublePrecision, json, geometry, index } = require('drizzle-orm/pg-core'); +const { pgTable, text, boolean, timestamp, doublePrecision, json, geometry, index, unique } = require('drizzle-orm/pg-core'); const { relations, sql } = require('drizzle-orm'); const { createId } = require('@paralleldrive/cuid2'); const { v4: uuidv4 } = require('uuid'); @@ -167,6 +167,15 @@ const accessToken = pgTable('access_token', { token: text('token'), }); +const measurement = pgTable('measurement', { + sensorId: text('sensor_id').notNull(), + time: timestamp('time', { precision: 3, withTimezone: true }).defaultNow() + .notNull(), + value: doublePrecision('value') +}, (t) => ({ + unq: unique().on(t.sensorId, t.time) +})); + /** * Relations */ @@ -228,6 +237,7 @@ module.exports.accessTokenTable = accessToken; module.exports.deviceTable = device; module.exports.sensorTable = sensor; module.exports.userTable = user; +module.exports.measurementTable = measurement; module.exports.passwordTable = password; module.exports.passwordResetTable = passwordReset; module.exports.profileTable = profile; diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index 6dd8b9f6..d248d19c 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -5,7 +5,7 @@ const { deviceTable, sensorTable, accessTokenTable } = require('../../schema/sch const sensorLayouts = require('../box/sensorLayouts'); const { db } = require('../drizzle'); const ModelError = require('../modelError'); -const { inArray, arrayContains } = require('drizzle-orm'); +const { inArray, arrayContains, sql } = require('drizzle-orm'); const buildWhereClause = function buildWhereClause (opts = {}) { const { phenomenon, fromDate, toDate, bbox, near, maxDistance, grouptag } = opts; @@ -141,10 +141,17 @@ const findDevicesMinimal = async function findDevicesMinimal (opts = {}, columns return devices; }; +const findTags = async function findTags () { + const tags = await db.execute(sql`SELECT array_agg(DISTINCT u.val) tags FROM device d CROSS JOIN LATERAL unnest(d.tags) AS u(val);`); + + return tags.rows[0].tags; +}; + module.exports = { createDevice, deleteDevice, findById, findDevices, - findDevicesMinimal + findDevicesMinimal, + findTags }; diff --git a/packages/models/src/drizzle.js b/packages/models/src/drizzle.js index 2c61dd62..d6d75fd3 100644 --- a/packages/models/src/drizzle.js +++ b/packages/models/src/drizzle.js @@ -15,7 +15,8 @@ const { userRelations, profileRelations, accessTokenRelations, - accessTokenTable + accessTokenTable, + measurementTable } = require('../schema/schema'); @@ -30,6 +31,7 @@ const schema = { userTable, passwordTable, passwordResetTable, + measurementTable, profileTable, profileImageTable, deviceRelations, diff --git a/packages/models/src/stats/index.js b/packages/models/src/stats/index.js index 8616fff3..5e50ec59 100644 --- a/packages/models/src/stats/index.js +++ b/packages/models/src/stats/index.js @@ -1,21 +1,26 @@ 'use strict'; -const { sql } = require('drizzle-orm'); +const { sql, and, count, gt, lt } = require('drizzle-orm'); const { db } = require('../drizzle'); -const count = async function count (table) { +const rowCount = async function rowCount (table) { const { rows } = await db.execute(sql`SELECT * FROM approximate_row_count(${table});`); const [count] = rows; - return count.approximate_row_count; + return Number(count.approximate_row_count); }; -const countTimeBucket = async function countTimeBucket (table, interval) { - // TODO: implement +const rowCountTimeBucket = async function rowCountTimeBucket (table, timeColumn, interval) { + const result = await db.select({ count: count() }).from(table) + .where(and(gt(table[timeColumn], new Date(Date.now() - interval), lt(table[timeColumn], new Date())))); + + const [rowCount] = result; + + return Number(rowCount.count); }; module.exports = { - count, - countTimeBucket + rowCount, + rowCountTimeBucket }; From 4f5b463a360bcb12a954c94001b5457c0f48bda0 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Tue, 1 Oct 2024 11:47:06 +0200 Subject: [PATCH 12/34] query latest measurement --- .../api/lib/controllers/boxesController.js | 4 + .../0011_drop_unused_columns_sensor.sql | 2 + .../models/migrations/meta/0011_snapshot.json | 730 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 7 + packages/models/schema/schema.js | 6 +- packages/models/src/measurement/index.js | 5 + packages/models/src/sensor/index.js | 26 + 7 files changed, 776 insertions(+), 4 deletions(-) create mode 100644 packages/models/migrations/0011_drop_unused_columns_sensor.sql create mode 100644 packages/models/migrations/meta/0011_snapshot.json create mode 100644 packages/models/src/measurement/index.js create mode 100644 packages/models/src/sensor/index.js diff --git a/packages/api/lib/controllers/boxesController.js b/packages/api/lib/controllers/boxesController.js index ad2fff23..2b9901c4 100644 --- a/packages/api/lib/controllers/boxesController.js +++ b/packages/api/lib/controllers/boxesController.js @@ -73,6 +73,7 @@ const const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/box'); const { createDevice, findDevices, findDevicesMinimal, findTags } = require('@sensebox/opensensemap-api-models/src/device'); const { findByUserId } = require('@sensebox/opensensemap-api-models/src/password'); +const { getSensorsWithLastMeasurement } = require('@sensebox/opensensemap-api-models/src/sensor'); const { removeDevice, checkPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); /** @@ -459,6 +460,9 @@ const getBox = async function getBox (req, res) { try { const device = await findDeviceById(boxId); + const sensorsWithMeasurements = await getSensorsWithLastMeasurement(boxId); + + device.sensors = sensorsWithMeasurements; if (format === 'geojson') { // Handle with PostGIS Extension const coordinates = [device.longitude, device.latitude]; diff --git a/packages/models/migrations/0011_drop_unused_columns_sensor.sql b/packages/models/migrations/0011_drop_unused_columns_sensor.sql new file mode 100644 index 00000000..1f0a35ce --- /dev/null +++ b/packages/models/migrations/0011_drop_unused_columns_sensor.sql @@ -0,0 +1,2 @@ +ALTER TABLE "sensor" DROP COLUMN IF EXISTS "lastMeasurement";--> statement-breakpoint +ALTER TABLE "sensor" DROP COLUMN IF EXISTS "data"; \ No newline at end of file diff --git a/packages/models/migrations/meta/0011_snapshot.json b/packages/models/migrations/meta/0011_snapshot.json new file mode 100644 index 00000000..fefc7a8f --- /dev/null +++ b/packages/models/migrations/meta/0011_snapshot.json @@ -0,0 +1,730 @@ +{ + "id": "54fd48ea-dce0-4ff8-b404-e58903008279", + "prevId": "f4cdd9e0-a1cf-4a53-af88-646898760135", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spatial_index": { + "name": "spatial_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "home_v2_lora", + "home_v2_ethernet", + "home_v2_ethernet_feinstaub", + "home_v2_wifi", + "home_v2_wifi_feinstaub", + "home_ethernet", + "home_wifi", + "home_ethernet_feinstaub", + "home_wifi_feinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "luftdaten_pms1003", + "luftdaten_pms1003_bme280", + "luftdaten_pms3003", + "luftdaten_pms3003_bme280", + "luftdaten_pms5003", + "luftdaten_pms5003_bme280", + "luftdaten_pms7003", + "luftdaten_pms7003_bme280", + "luftdaten_sps30_bme280", + "luftdaten_sps30_sht3x", + "hackair_home_v2", + "custom" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json index 65642189..c0076c7d 100644 --- a/packages/models/migrations/meta/_journal.json +++ b/packages/models/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1727701542050, "tag": "0010_add_device_tags", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1727774100701, + "tag": "0011_drop_unused_columns_sensor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js index 31e57dd7..dbb35e8a 100644 --- a/packages/models/schema/schema.js +++ b/packages/models/schema/schema.js @@ -1,6 +1,6 @@ 'use strict'; -const { pgTable, text, boolean, timestamp, doublePrecision, json, geometry, index, unique } = require('drizzle-orm/pg-core'); +const { pgTable, text, boolean, timestamp, doublePrecision, geometry, index, unique } = require('drizzle-orm/pg-core'); const { relations, sql } = require('drizzle-orm'); const { createId } = require('@paralleldrive/cuid2'); const { v4: uuidv4 } = require('uuid'); @@ -60,9 +60,7 @@ const sensor = pgTable('sensor', { }), sensorWikiType: text('sensor_wiki_type'), sensorWikiPhenomenon: text('sensor_wiki_phenomenon'), - sensorWikiUnit: text('sensor_wiki_unit'), - lastMeasurement: json('lastMeasurement'), - data: json('data') + sensorWikiUnit: text('sensor_wiki_unit') }); const user = pgTable('user', { diff --git a/packages/models/src/measurement/index.js b/packages/models/src/measurement/index.js new file mode 100644 index 00000000..69979913 --- /dev/null +++ b/packages/models/src/measurement/index.js @@ -0,0 +1,5 @@ +'use strict'; + + + +module.exports = {}; diff --git a/packages/models/src/sensor/index.js b/packages/models/src/sensor/index.js new file mode 100644 index 00000000..8de157d1 --- /dev/null +++ b/packages/models/src/sensor/index.js @@ -0,0 +1,26 @@ +'use strict'; + +const { sql } = require('drizzle-orm'); +const { db } = require('../drizzle'); + +// LATERAL JOIN to get latest measurement for sensors belonging to a specific device, including device name +const getSensorsWithLastMeasurement = async function getSensorsWithLastMeasurement (deviceId) { + const { rows } = await db.execute( + sql`SELECT s.title, s.unit, s.sensor_type, json_object(ARRAY['value', 'createdAt'], ARRAY[CAST(measure.value AS TEXT),CAST(measure.time AS TEXT)]) AS "lastMeasurement" + FROM sensor s + JOIN device d ON s.device_id = d.id + LEFT JOIN LATERAL ( + SELECT * FROM measurement m + WHERE m.sensor_id = s.id + ORDER BY m.time DESC + LIMIT 1 + ) AS measure ON true + WHERE s.device_id = ${deviceId};`, + ); + + return rows; +}; + +module.exports = { + getSensorsWithLastMeasurement +}; From f9458c74b89561ac53a035435b7f7f9941e7f368 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Tue, 1 Oct 2024 12:33:34 +0200 Subject: [PATCH 13/34] insert measurement --- .../lib/controllers/measurementsController.js | 15 ++++++---- packages/models/src/device/index.js | 29 +++++++++++++++++-- .../src/measurement/decoding/validators.js | 5 ++-- packages/models/src/measurement/index.js | 13 ++++++++- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/packages/api/lib/controllers/measurementsController.js b/packages/api/lib/controllers/measurementsController.js index c85a6f4b..568e54ac 100644 --- a/packages/api/lib/controllers/measurementsController.js +++ b/packages/api/lib/controllers/measurementsController.js @@ -1,5 +1,6 @@ 'use strict'; +const { findAccessToken, findById, saveMeasurement } = require('@sensebox/opensensemap-api-models/src/device'); const { BadRequestError, UnsupportedMediaTypeError, @@ -353,12 +354,13 @@ const getDataByGroupTag = async function getDataByGroupTag (req, res) { * @apiHeader {String} Authorization Box' unique access_token. Will be used as authorization token if box has auth enabled (e.g. useAuth: true) */ const postNewMeasurement = async function postNewMeasurement (req, res) { - const { boxId, sensorId, value, createdAt, location } = req._userParams; + const { boxId: deviceId, sensorId, value, createdAt, location } = req._userParams; try { - const box = await Box.findBoxById(boxId, { populate: false, lean: false }); - if (box.useAuth && box.access_token && box.access_token !== req.headers.authorization) { - return Promise.reject(new UnauthorizedError('Box access token not valid!')); + const device = await findById(deviceId, { sensors: true }); + const deviceAccessToken = await findAccessToken(deviceId); + if (device.useAuth && deviceAccessToken.token && deviceAccessToken.token !== req.headers.authorization) { + return Promise.reject(new UnauthorizedError('Device access token not valid!')); } const [measurement] = await Measurement.decodeMeasurements([{ @@ -367,8 +369,9 @@ const postNewMeasurement = async function postNewMeasurement (req, res) { createdAt, location }]); - await box.saveMeasurement(measurement); - res.send(201, 'Measurement saved in box'); + // await box.saveMeasurement(measurement); + await saveMeasurement(device, measurement); + res.send(201, 'Measurement saved in device'); } catch (err) { return handleError(err); } diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index d248d19c..e3a960ea 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -6,6 +6,7 @@ const sensorLayouts = require('../box/sensorLayouts'); const { db } = require('../drizzle'); const ModelError = require('../modelError'); const { inArray, arrayContains, sql } = require('drizzle-orm'); +const { insertMeasurement } = require('../measurement'); const buildWhereClause = function buildWhereClause (opts = {}) { const { phenomenon, fromDate, toDate, bbox, near, maxDistance, grouptag } = opts; @@ -111,9 +112,10 @@ const deleteDevice = async function (filter) { .returning(); }; -const findById = async function findById (deviceId) { +const findById = async function findById (deviceId, relations) { const device = await db.query.deviceTable.findFirst({ - where: (device, { eq }) => eq(device.id, deviceId) + where: (device, { eq }) => eq(device.id, deviceId), + ...(Object.keys(relations).length !== 0 && { with: relations }) }); return device; @@ -147,11 +149,32 @@ const findTags = async function findTags () { return tags.rows[0].tags; }; +const findAccessToken = async function findAccessToken (deviceId) { + const token = await db.query.accessTokenTable.findFirst({ + where: (token, { eq }) => eq(token.deviceId, deviceId) + }); + + return token; +}; + +const saveMeasurement = async function saveMeasurement (device, measurement) { + + const sensor = device.sensors.find(sensor => sensor.id === measurement.sensor_id); + + if (!sensor) { + throw new ModelError(`Sensor not found: Sensor ${measurement.sensor_id} of box ${device.id} not found`, { type: 'NotFoundError' }); + } + + await insertMeasurement(measurement); +}; + module.exports = { createDevice, deleteDevice, findById, findDevices, findDevicesMinimal, - findTags + findTags, + findAccessToken, + saveMeasurement }; diff --git a/packages/models/src/measurement/decoding/validators.js b/packages/models/src/measurement/decoding/validators.js index 18053858..7564e664 100644 --- a/packages/models/src/measurement/decoding/validators.js +++ b/packages/models/src/measurement/decoding/validators.js @@ -1,5 +1,6 @@ 'use strict'; +const { isCuid } = require('@paralleldrive/cuid2'); const { parseAndValidateTimestamp, isNonEmptyString, isNumeric, utcNow } = require('../../utils'), { mongoose } = require('../../db'); @@ -12,7 +13,7 @@ const validateMeasurementPrimitives = function validateMeasurementPrimitives ({ throw new Error('Missing sensor id'); } - if (!mongoose.Types.ObjectId.isValid(sensor_id) || sensor_id === '00112233445566778899aabb') { + if (!isCuid(sensor_id) || sensor_id === '00112233445566778899aabb') { throw new Error('Invalid sensor id'); } @@ -69,7 +70,7 @@ const transformAndValidateMeasurements = function transformAndValidateMeasuremen } // finally attach a mongodb objectId - elem._id = mongoose.Types.ObjectId(); + // elem._id = mongoose.Types.ObjectId(); } // sort measurements/locations by date arr.sort((a, b) => a.createdAt.diff(b.createdAt)); diff --git a/packages/models/src/measurement/index.js b/packages/models/src/measurement/index.js index 69979913..bbd92c74 100644 --- a/packages/models/src/measurement/index.js +++ b/packages/models/src/measurement/index.js @@ -1,5 +1,16 @@ 'use strict'; +const { measurementTable } = require('../../schema/schema'); +const { db } = require('../drizzle'); +const insertMeasurement = async function insertMeasurement (measurement) { + return db.insert(measurementTable).values({ + sensorId: measurement.sensor_id, + value: measurement.value, + time: measurement.createAt + }); +}; -module.exports = {}; +module.exports = { + insertMeasurement +}; From 77188674f524ba5e35b90c2240de102576716b60 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Tue, 8 Oct 2024 20:42:37 +0200 Subject: [PATCH 14/34] measurement stuff --- .../lib/controllers/measurementsController.js | 22 +++++++------ packages/models/src/device/index.js | 32 +++++++++++++++++-- packages/models/src/measurement/index.js | 26 ++++++++++++++- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/packages/api/lib/controllers/measurementsController.js b/packages/api/lib/controllers/measurementsController.js index 568e54ac..b32e6b32 100644 --- a/packages/api/lib/controllers/measurementsController.js +++ b/packages/api/lib/controllers/measurementsController.js @@ -1,6 +1,7 @@ 'use strict'; -const { findAccessToken, findById, saveMeasurement } = require('@sensebox/opensensemap-api-models/src/device'); +const { findAccessToken, findById, saveMeasurement, saveMeasurements } = require('@sensebox/opensensemap-api-models/src/device'); +const { hasDecoder, decodeMeasurements } = require('@sensebox/opensensemap-api-models/src/measurement'); const { BadRequestError, UnsupportedMediaTypeError, @@ -363,7 +364,7 @@ const postNewMeasurement = async function postNewMeasurement (req, res) { return Promise.reject(new UnauthorizedError('Device access token not valid!')); } - const [measurement] = await Measurement.decodeMeasurements([{ + const [measurement] = await decodeMeasurements([{ sensor_id: sensorId, value, createdAt, @@ -457,7 +458,7 @@ const postNewMeasurement = async function postNewMeasurement (req, res) { * } */ const postNewMeasurements = async function postNewMeasurements (req, res) { - const { boxId, luftdaten, hackair } = req._userParams; + const { boxId: deviceId, luftdaten, hackair } = req._userParams; let contentType = req.getContentType(); if (hackair) { @@ -466,21 +467,24 @@ const postNewMeasurements = async function postNewMeasurements (req, res) { contentType = 'luftdaten'; } - if (Measurement.hasDecoder(contentType)) { + if (hasDecoder(contentType)) { try { - const box = await Box.findBoxById(boxId, { populate: false, lean: false, projection: { sensors: 1, locations: 1, lastMeasurementAt: 1, currentLocation: 1, model: 1, access_token: 1, useAuth: 1 } }); + const device = await findById(deviceId, { sensors: true }); + const deviceAccessToken = await findAccessToken(deviceId); + // const box = await Box.findBoxById(boxId, { populate: false, lean: false, projection: { sensors: 1, locations: 1, lastMeasurementAt: 1, currentLocation: 1, model: 1, access_token: 1, useAuth: 1 } }); // if (contentType === 'hackair' && box.access_token !== req.headers.authorization) { // throw new UnauthorizedError('Box access token not valid!'); // } // authorization for all boxes that have not opt out - if ((box.useAuth || contentType === 'hackair') && box.access_token && box.access_token !== req.headers.authorization) { - return Promise.reject(new UnauthorizedError('Box access token not valid!')); + if ((device.useAuth || contentType === 'hackair') && deviceAccessToken.token && deviceAccessToken.token !== req.headers.authorization) { + return Promise.reject(new UnauthorizedError('Device access token not valid!')); } - const measurements = await Measurement.decodeMeasurements(req.body, { contentType, sensors: box.sensors }); - await box.saveMeasurementsArray(measurements); + const measurements = await decodeMeasurements(req.body, { contentType, sensors: device.sensors }); + // await box.saveMeasurementsArray(measurements); + await saveMeasurements(device, measurements); res.send(201, 'Measurements saved in box'); } catch (err) { return handleError(err); diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index e3a960ea..2c5ec9f8 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -6,7 +6,7 @@ const sensorLayouts = require('../box/sensorLayouts'); const { db } = require('../drizzle'); const ModelError = require('../modelError'); const { inArray, arrayContains, sql } = require('drizzle-orm'); -const { insertMeasurement } = require('../measurement'); +const { insertMeasurement, insertMeasurements } = require('../measurement'); const buildWhereClause = function buildWhereClause (opts = {}) { const { phenomenon, fromDate, toDate, bbox, near, maxDistance, grouptag } = opts; @@ -168,6 +168,33 @@ const saveMeasurement = async function saveMeasurement (device, measurement) { await insertMeasurement(measurement); }; +const saveMeasurements = async function saveMeasurements (device, measurements) { + + if (!Array.isArray(measurements)) { + return Promise.reject(new Error('Array expected')); + } + + const sensorIds = this.sensorIds(), + lastMeasurements = {}; + + // TODO: refactor + // find new lastMeasurements + // check if all the measurements belong to this box + for (let i = measurements.length - 1; i >= 0; i--) { + if (!sensorIds.includes(measurements[i].sensor_id)) { + return Promise.reject(new ModelError(`Measurement for sensor with id ${measurements[i].sensor_id} does not belong to box`)); + } + + if (!lastMeasurements[measurements[i].sensor_id]) { + lastMeasurements[measurements[i].sensor_id] = measurements[i]; + } + } + + // TODO: check if we can merge this with `saveMeasurement` + + await insertMeasurements(measurements); +}; + module.exports = { createDevice, deleteDevice, @@ -176,5 +203,6 @@ module.exports = { findDevicesMinimal, findTags, findAccessToken, - saveMeasurement + saveMeasurement, + saveMeasurements }; diff --git a/packages/models/src/measurement/index.js b/packages/models/src/measurement/index.js index bbd92c74..6fc74554 100644 --- a/packages/models/src/measurement/index.js +++ b/packages/models/src/measurement/index.js @@ -2,6 +2,23 @@ const { measurementTable } = require('../../schema/schema'); const { db } = require('../drizzle'); +const ModelError = require('../modelError'); +const decodeHandlers = require('./decoding'); + +const hasDecoder = function hasDecoder (contentType) { + if (!decodeHandlers[contentType]) { + return false; + } + + return true; +}; + +const decodeMeasurements = function decodeMeasurements (measurements, { contentType = 'json', sensors } = {}) { + return decodeHandlers[contentType].decodeMessage(measurements, { sensors }) + .catch(function (err) { + throw new ModelError(err.message, { type: 'UnprocessableEntityError' }); + }); +}; const insertMeasurement = async function insertMeasurement (measurement) { return db.insert(measurementTable).values({ @@ -11,6 +28,13 @@ const insertMeasurement = async function insertMeasurement (measurement) { }); }; +const insertMeasurements = async function insertMeasurements (measurements) { + return db.insert(measurementTable).values(measurements); +}; + module.exports = { - insertMeasurement + decodeMeasurements, + hasDecoder, + insertMeasurement, + insertMeasurements }; From 60283fcb698098bf5d7d5eb92012a67ba0bd8908 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Thu, 24 Oct 2024 20:43:06 +0200 Subject: [PATCH 15/34] reorder things --- .gitignore | 1 + docker-compose.yml | 9 +- .../api/lib/controllers/boxesController.js | 15 +- .../api/lib/controllers/usersController.js | 4 +- packages/models/src/box/box.js | 1 - packages/models/src/device/index.js | 122 +++++++++++++++- packages/models/src/password/utils.js | 41 ++++++ packages/models/src/user/index.js | 137 ++++++++++++++++++ packages/models/src/user/user.js | 43 +----- tests/docker-compose.yml | 2 +- 10 files changed, 319 insertions(+), 56 deletions(-) create mode 100644 packages/models/src/password/utils.js create mode 100644 packages/models/src/user/index.js diff --git a/.gitignore b/.gitignore index f4867d4a..916a9f13 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ lib-cov *.gz .vscode .yarn-cache +.DS_Store pids logs diff --git a/docker-compose.yml b/docker-compose.yml index eae50102..dd35b5e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ +volumes: + opensensemap_backend: + services: db: - image: timescale/timescaledb-ha:pg15-latest + image: timescale/timescaledb-ha:pg15.8-ts2.17.1 command: - -cshared_preload_libraries=timescaledb,pg_cron restart: always @@ -9,4 +12,6 @@ services: - POSTGRES_PASSWORD=postgres - POSTGRES_DB=opensensemap ports: - - 5432:5432 \ No newline at end of file + - 5432:5432 + volumes: + - opensensemap_backend:/home/postgres/pgdata \ No newline at end of file diff --git a/packages/api/lib/controllers/boxesController.js b/packages/api/lib/controllers/boxesController.js index 2b9901c4..b2ff4309 100644 --- a/packages/api/lib/controllers/boxesController.js +++ b/packages/api/lib/controllers/boxesController.js @@ -71,7 +71,7 @@ const handleError = require('../helpers/errorHandler'), jsonstringify = require('stringify-stream'); const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/box'); -const { createDevice, findDevices, findDevicesMinimal, findTags } = require('@sensebox/opensensemap-api-models/src/device'); +const { createDevice, findDevices, findDevicesMinimal, findTags, updateDevice } = require('@sensebox/opensensemap-api-models/src/device'); const { findByUserId } = require('@sensebox/opensensemap-api-models/src/password'); const { getSensorsWithLastMeasurement } = require('@sensebox/opensensemap-api-models/src/sensor'); const { removeDevice, checkPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); @@ -159,13 +159,14 @@ const { removeDevice, checkPassword } = require('@sensebox/opensensemap-api-mode */ const updateBox = async function updateBox (req, res) { try { - let box = await Box.findBoxById(req._userParams.boxId, { lean: false, populate: false }); - box = await box.updateBox(req._userParams); - if (box._sensorsChanged === true) { - req.user.mail('newSketch', box); - } + let device = await findDeviceById(req._userParams.boxId); + device = await updateDevice(device.id, req._userParams); + // if (box._sensorsChanged === true) { + // req.user.mail('newSketch', box); + // } - res.send({ code: 'Ok', data: box.toJSON({ includeSecrets: true }) }); + // res.send({ code: 'Ok', data: box.toJSON({ includeSecrets: true }) }); + res.send({ code: 'Ok', data: device }); clearCache(['getBoxes']); } catch (err) { return handleError(err); diff --git a/packages/api/lib/controllers/usersController.js b/packages/api/lib/controllers/usersController.js index f1eef567..4daec2e0 100644 --- a/packages/api/lib/controllers/usersController.js +++ b/packages/api/lib/controllers/usersController.js @@ -15,7 +15,9 @@ const { User } = require('@sensebox/opensensemap-api-models'), refreshJwt, invalidateToken, } = require('../helpers/jwtHelpers'); -const { createUser, findUserByNameOrEmail, checkPassword, initPasswordReset, resetOldPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); +const { checkPassword } = require('@sensebox/opensensemap-api-models/src/password/utils'); +const { createUser } = require('@sensebox/opensensemap-api-models/src/user'); +const { findUserByNameOrEmail, initPasswordReset, resetOldPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); /** * define for nested user parameter for box creation request diff --git a/packages/models/src/box/box.js b/packages/models/src/box/box.js index a2f3455d..c3817dc1 100644 --- a/packages/models/src/box/box.js +++ b/packages/models/src/box/box.js @@ -1,6 +1,5 @@ 'use strict'; -const { exposure } = require('../../schema/enum'); const { db } = require('../drizzle'); const { mongoose } = require('../db'), diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index 2c5ec9f8..02ff97f4 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -5,7 +5,7 @@ const { deviceTable, sensorTable, accessTokenTable } = require('../../schema/sch const sensorLayouts = require('../box/sensorLayouts'); const { db } = require('../drizzle'); const ModelError = require('../modelError'); -const { inArray, arrayContains, sql } = require('drizzle-orm'); +const { inArray, arrayContains, sql, eq } = require('drizzle-orm'); const { insertMeasurement, insertMeasurements } = require('../measurement'); const buildWhereClause = function buildWhereClause (opts = {}) { @@ -112,7 +112,7 @@ const deleteDevice = async function (filter) { .returning(); }; -const findById = async function findById (deviceId, relations) { +const findById = async function findById (deviceId, relations = {}) { const device = await db.query.deviceTable.findFirst({ where: (device, { eq }) => eq(device.id, deviceId), ...(Object.keys(relations).length !== 0 && { with: relations }) @@ -195,8 +195,126 @@ const saveMeasurements = async function saveMeasurements (device, measurements) await insertMeasurements(measurements); }; +const updateDevice = async function updateDevice (deviceId, args) { + const { + mqtt: { + enabled, + url, + topic, + decodeOptions: mqttDecodeOptions, + connectionOptions, + messageFormat + } = {}, + ttn: { + app_id, + dev_id, + port, + profile, + decodeOptions: ttnDecodeOptions + } = {}, + location, + sensors, + addons: { add: addonToAdd } = {} + } = args; + + if (args.mqtt) { + args['integrations.mqtt'] = { + enabled, + url, + topic, + decodeOptions: mqttDecodeOptions, + connectionOptions, + messageFormat + }; + } + if (args.ttn) { + args['integrations.ttn'] = { + app_id, + dev_id, + port, + profile, + decodeOptions: ttnDecodeOptions + }; + } + + if (args.mqtt) { + args['integrations.mqtt'] = { + enabled, + url, + topic, + decodeOptions: mqttDecodeOptions, + connectionOptions, + messageFormat + }; + } + if (args.ttn) { + args['integrations.ttn'] = { + app_id, + dev_id, + port, + profile, + decodeOptions: ttnDecodeOptions + }; + } + + const setColumns = {}; + for (const prop of [ + 'name', + 'exposure', + 'grouptag', + 'description', + 'weblink', + 'image', + // 'integrations.mqtt', + // 'integrations.ttn', + 'model', + 'useAuth' + ]) { + if (typeof args[prop] !== 'undefined') { + setColumns[prop] = args[prop]; + + if (prop === 'grouptag') { + setColumns['tags'] = args[prop]; + } + } + } + + // TODO: generate new access token + // if user wants a new access_token + // if (typeof args['generate_access_token'] !== 'undefined') { + // if (args['generate_access_token'] === 'true') { + // // Create new acces token for box + // const access_token = crypto.randomBytes(32).toString('hex'); + // box.set('access_token', access_token); + // } + // } + + // TODO update sensors + // if (sensors) { + // box.updateSensors(sensors); + // } else if (addonToAdd) { + // box.addAddon(addonToAdd); + // } + + // TODO: run location update logic, if a location was provided. + // const locPromise = location + // ? box + // .updateLocation(location) + // .then((loc) => box.set({ currentLocation: loc })) + // : Promise.resolve(); + + const device = await db + .update(deviceTable) + .set(setColumns) + .where(eq(deviceTable.id, deviceId)) + .returning(); + + return device[0]; +}; + module.exports = { createDevice, + updateDevice, deleteDevice, findById, findDevices, diff --git a/packages/models/src/password/utils.js b/packages/models/src/password/utils.js new file mode 100644 index 00000000..e20466bd --- /dev/null +++ b/packages/models/src/password/utils.js @@ -0,0 +1,41 @@ +'use strict'; + +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); + +const ModelError = require('../modelError'); + +const { min_length: password_min_length, salt_factor: password_salt_factor } = require('config').get('openSenseMap-API-models.password'); + +const preparePasswordHash = function preparePasswordHash (plaintextPassword) { + // first round: hash plaintextPassword with sha512 + const hash = crypto.createHash('sha512'); + hash.update(plaintextPassword.toString(), 'utf8'); + const hashed = hash.digest('base64'); // base64 for more entropy than hex + + return hashed; +}; + +const checkPassword = function checkPassword ( + plaintextPassword, + hashedPassword +) { + return bcrypt + .compare(preparePasswordHash(plaintextPassword), hashedPassword.hash) + .then(function (passwordIsCorrect) { + if (passwordIsCorrect === false) { + throw new ModelError('Password incorrect', { type: 'ForbiddenError' }); + } + + return true; + }); +}; + +const validatePassword = function validatePassword (newPassword) { + return newPassword.length >= Number(password_min_length); +}; + +module.exports = { + checkPassword, + validatePassword +}; diff --git a/packages/models/src/user/index.js b/packages/models/src/user/index.js new file mode 100644 index 00000000..ea1c6206 --- /dev/null +++ b/packages/models/src/user/index.js @@ -0,0 +1,137 @@ +'use strict'; + +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); + +const { userTable, passwordTable } = require('../../schema/schema'); +const { db } = require('../drizzle'); +const { createProfile } = require('../profile/profile'); +const { eq } = require('drizzle-orm'); +const ModelError = require('../modelError'); +const { checkPassword, validatePassword } = require('../password/utils'); + +// Configuration +const { min_length: password_min_length, salt_factor: password_salt_factor } = require('config').get('openSenseMap-API-models.password'); + +const preparePasswordHash = function preparePasswordHash (plaintextPassword) { + // first round: hash plaintextPassword with sha512 + const hash = crypto.createHash('sha512'); + hash.update(plaintextPassword.toString(), 'utf8'); + const hashed = hash.digest('base64'); // base64 for more entropy than hex + + return hashed; +}; + +const passwordHasher = function passwordHasher (plaintextPassword) { + return bcrypt.hash( + preparePasswordHash(plaintextPassword), + Number(password_salt_factor) + ); // signature generates a salt and hashes in one step +}; + +const createUser = async function createUser (name, email, password, language) { + try { + const hashedPassword = await passwordHasher(password); + const user = await db + .insert(userTable) + .values({ name, email, language }) + .returning(); + + await db.insert(passwordTable).values({ + hash: hashedPassword, + userId: user[0].id + }); + + await createProfile(user[0]); + + // TODO: Only return specific fields + return user[0]; + } catch (error) { + console.log(error); + } +}; + +// TODO: delete User +const deleteUser = async function deleteUser () {}; + +const updateUser = async function updateUser ( + userId, + { email, language, name, currentPassword, newPassword, integrations } +) { + // don't allow email and password change in one request + if (email && newPassword) { + return Promise.reject( + new ModelError( + 'You cannot change your email address and password in the same request.' + ) + ); + } + + // for password and email changes, require parameter currentPassword to be valid. + if ((newPassword && newPassword !== '') || (email && email !== '')) { + // check if the request includes the old password + if (!currentPassword) { + return Promise.reject( + new ModelError( + 'To change your password or email address, please supply your current password.' + ) + ); + } + await checkPassword(currentPassword); + + // check new password against password rules + if (newPassword && validatePassword(newPassword) === false) { + return Promise.reject( + new ModelError('New password should have at least 8 characters') + ); + } + } + + // at this point its clear the user is allowed to change the details of their profile + const setColumns = {}; + + const msgs = []; + let signOut = false, + somethingsChanged = false; + + // we only set changed properties + if (name && user.name !== name) { + user.set('name', name); + somethingsChanged = true; + } + + if (language && user.language !== language) { + user.set('language', language); + somethingsChanged = true; + } + + if (email && user.email !== email) { + user.set('newEmail', email); + msgs.push( + ' E-Mail changed. Please confirm your new address. Until confirmation, sign in using your old address' + ); + somethingsChanged = true; + } + + // at this point its also clear the new password conforms to the password rules + if (newPassword) { + user.set('password', newPassword); + msgs.push(' Password changed. Please sign in with your new password'); + signOut = true; + somethingsChanged = true; + } + + const user = await db + .update(userTable) + .set(setColumns) + .where(eq(userTable.id, userId)) + .returning(); + + return user[0]; +}; + +module.exports = { + createUser, + deleteUser, + updateUser +}; diff --git a/packages/models/src/user/user.js b/packages/models/src/user/user.js index e957dff4..b6af182b 100644 --- a/packages/models/src/user/user.js +++ b/packages/models/src/user/user.js @@ -26,8 +26,7 @@ const { mongoose } = require('../db'), ModelError = require('../modelError'), isemail = require('isemail'); -const { createProfile } = require('../profile/profile'); -const { userTable, passwordTable, passwordResetTable, deviceTable } = require('../../schema/schema'); +const { passwordTable, passwordResetTable } = require('../../schema/schema'); const { eq } = require('drizzle-orm'); const { findById, deleteDevice } = require('../device'); @@ -722,30 +721,6 @@ integrations.addToSchema(userSchema); const userModel = mongoose.model('User', userSchema); -const createUser = async function createUser (name, email, password, language) { - try { - const hashedPassword = await passwordHasher(password); - const user = await db.insert(userTable).values({ name, email, language }) - .returning(); - - await db.insert(passwordTable).values({ - hash: hashedPassword, - userId: user[0].id - }); - - await createProfile(user[0]); - - // TODO: Only return specific fields - return user[0]; - } catch (error) { - console.log(error); - } -}; - -const deleteUser = async function deleteUser () {}; - -const updateUser = async function updateUser () {}; - const findUserByNameOrEmail = async function findUserByNameOrEmail (emailOrName) { return db.query.userTable.findFirst({ where: (user, { eq, or }) => or(eq(user.email, emailOrName.toLowerCase()), eq(user.name, emailOrName)), @@ -755,18 +730,6 @@ const findUserByNameOrEmail = async function findUserByNameOrEmail (emailOrName) }); }; -const checkPassword = function checkPassword (plaintextPassword, hashedPassword) { - return bcrypt - .compare(preparePasswordHash(plaintextPassword), hashedPassword.hash) - .then(function (passwordIsCorrect) { - if (passwordIsCorrect === false) { - throw new ModelError('Password incorrect', { type: 'ForbiddenError' }); - } - - return true; - }); -}; - const initPasswordReset = async function initPasswordReset ({ email }) { const user = await db.query.userTable.findFirst({ @@ -864,12 +827,8 @@ const removeDevice = async function removeDevice (deviceId) { module.exports = { schema: userSchema, model: userModel, - createUser, - deleteUser, - updateUser, findUserByNameOrEmail, findUserByEmailAndRole, - checkPassword, initPasswordReset, resetOldPassword, removeDevice, diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 4f4e11c6..952053fe 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -116,7 +116,7 @@ services: - 6379:6379 db: - image: timescale/timescaledb-ha:pg15-latest + image: timescale/timescaledb-ha:pg15.8-ts2.17.1 command: - -cshared_preload_libraries=timescaledb,pg_cron restart: always From 74a27da4964fb6df05b453ce80565100983be6ba Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Fri, 25 Oct 2024 13:38:27 +0200 Subject: [PATCH 16/34] more stuff migrated --- .../api/lib/controllers/boxesController.js | 10 +- .../api/lib/controllers/usersController.js | 20 +- packages/api/lib/helpers/jwtHelpers.js | 8 +- packages/api/lib/helpers/tokenBlacklist.js | 62 +- .../migrations/0012_add_token_blacklist.sql | 5 + .../models/migrations/meta/0012_snapshot.json | 758 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 7 + packages/models/schema/schema.js | 11 + packages/models/src/device/index.js | 61 +- packages/models/src/password/index.js | 77 +- packages/models/src/password/utils.js | 10 +- packages/models/src/token/index.js | 26 + packages/models/src/user/index.js | 42 +- packages/models/src/user/user.js | 75 -- 14 files changed, 1024 insertions(+), 148 deletions(-) create mode 100644 packages/models/migrations/0012_add_token_blacklist.sql create mode 100644 packages/models/migrations/meta/0012_snapshot.json create mode 100644 packages/models/src/token/index.js diff --git a/packages/api/lib/controllers/boxesController.js b/packages/api/lib/controllers/boxesController.js index b2ff4309..aa083f2e 100644 --- a/packages/api/lib/controllers/boxesController.js +++ b/packages/api/lib/controllers/boxesController.js @@ -71,7 +71,7 @@ const handleError = require('../helpers/errorHandler'), jsonstringify = require('stringify-stream'); const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/box'); -const { createDevice, findDevices, findDevicesMinimal, findTags, updateDevice } = require('@sensebox/opensensemap-api-models/src/device'); +const { createDevice, findDevices, findDevicesMinimal, findTags, updateDevice, findById, generateSketch } = require('@sensebox/opensensemap-api-models/src/device'); const { findByUserId } = require('@sensebox/opensensemap-api-models/src/password'); const { getSensorsWithLastMeasurement } = require('@sensebox/opensensemap-api-models/src/sensor'); const { removeDevice, checkPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); @@ -548,7 +548,7 @@ const postNewBox = async function postNewBox (req, res) { const getSketch = async function getSketch (req, res) { res.header('Content-Type', 'text/plain; charset=utf-8'); try { - const box = await Box.findBoxById(req._userParams.boxId, { populate: false, lean: false }); + const device = await findById(req._userParams.boxId, { accessToken: true, sensors: true }); const params = { serialPort: req._userParams.serialPort, @@ -564,11 +564,11 @@ const getSketch = async function getSketch (req, res) { }; // pass access token only if useAuth is true and access_token is available - if (box.access_token) { - params.access_token = box.access_token; + if (device.useAuth && device.accessToken) { + params.access_token = device.accessToken.token; } - res.send(box.getSketch(params)); + res.send(generateSketch(device, params)); } catch (err) { return handleError(err); } diff --git a/packages/api/lib/controllers/usersController.js b/packages/api/lib/controllers/usersController.js index 4daec2e0..2b5667e3 100644 --- a/packages/api/lib/controllers/usersController.js +++ b/packages/api/lib/controllers/usersController.js @@ -15,9 +15,11 @@ const { User } = require('@sensebox/opensensemap-api-models'), refreshJwt, invalidateToken, } = require('../helpers/jwtHelpers'); +const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/box'); +const { findDevices, findDevicesByUserId } = require('@sensebox/opensensemap-api-models/src/device'); +const { initPasswordReset, resetOldPassword } = require('@sensebox/opensensemap-api-models/src/password'); const { checkPassword } = require('@sensebox/opensensemap-api-models/src/password/utils'); -const { createUser } = require('@sensebox/opensensemap-api-models/src/user'); -const { findUserByNameOrEmail, initPasswordReset, resetOldPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); +const { createUser, findUserByNameOrEmail } = require('@sensebox/opensensemap-api-models/src/user'); /** * define for nested user parameter for box creation request @@ -256,11 +258,15 @@ const confirmEmailAddress = async function confirmEmailAddress (req, res) { const getUserBoxes = async function getUserBoxes (req, res) { const { page } = req._userParams; try { - const boxes = await req.user.getBoxes(page); - const sharedBoxes = await req.user.getSharedBoxes(); + const devices = await findDevicesByUserId(req.user.id, { page }); + // const sharedBoxes = await req.user.getSharedBoxes(); res.send(200, { code: 'Ok', - data: { boxes: boxes, boxes_count: req.user.boxes.length, sharedBoxes: sharedBoxes }, + data: { + boxes: devices, + boxes_count: devices.length, + sharedBoxes: [] + }, }); } catch (err) { return handleError(err); @@ -279,11 +285,11 @@ const getUserBoxes = async function getUserBoxes (req, res) { const getUserBox = async function getUserBox (req, res) { const { boxId } = req._userParams; try { - const box = await req.user.getBox(boxId); + const device = await findDeviceById(boxId); res.send(200, { code: 'Ok', data: { - box + box: device }, }); } catch (err) { diff --git a/packages/api/lib/helpers/jwtHelpers.js b/packages/api/lib/helpers/jwtHelpers.js index 50db4f07..7655e863 100644 --- a/packages/api/lib/helpers/jwtHelpers.js +++ b/packages/api/lib/helpers/jwtHelpers.js @@ -1,7 +1,7 @@ 'use strict'; const { addRefreshToken, deleteRefreshToken } = require('@sensebox/opensensemap-api-models/src/token/refresh'); -const { findUserByEmailAndRole } = require('@sensebox/opensensemap-api-models/src/user/user'); +const { findUserByEmailAndRole } = require('@sensebox/opensensemap-api-models/src/user'); const config = require('config'), jwt = require('jsonwebtoken'), hashJWT = require('./jwtRefreshTokenHasher'), @@ -57,7 +57,7 @@ const invalidateToken = async function invalidateToken ({ user, _jwt, _jwtString // createToken(user); // TODO: why do we create a new token here?!?! const hash = hashJWT(_jwtString); await deleteRefreshToken(hash); - // addTokenToBlacklist(_jwt, _jwtString); + addTokenToBlacklist(_jwt, _jwtString); }; const refreshJwt = async function refreshJwt (refreshToken) { @@ -89,14 +89,14 @@ const verifyJwt = function verifyJwt (req, res, next) { return next(new ForbiddenError(jwtInvalidErrorMessage)); } - jwt.verify(jwtString, jwt_secret, jwtVerifyOptions, function (err, decodedJwt) { + jwt.verify(jwtString, jwt_secret, jwtVerifyOptions, async function (err, decodedJwt) { if (err) { return next(new ForbiddenError(jwtInvalidErrorMessage)); } // check if the token is blacklisted by performing a hmac digest on the string representation of the jwt. // also checks the existence of the jti claim - if (isTokenBlacklisted(decodedJwt, jwtString)) { + if (await isTokenBlacklisted(decodedJwt, jwtString)) { return next(new ForbiddenError(jwtInvalidErrorMessage)); } diff --git a/packages/api/lib/helpers/tokenBlacklist.js b/packages/api/lib/helpers/tokenBlacklist.js index 24a7ee16..552c3ef5 100644 --- a/packages/api/lib/helpers/tokenBlacklist.js +++ b/packages/api/lib/helpers/tokenBlacklist.js @@ -1,32 +1,28 @@ 'use strict'; -const moment = require('moment'), - hashJWT = require('./jwtRefreshTokenHasher'); - -// our token blacklist is just a js object with -// jtis as keys and all claims as values -const tokenBlacklist = Object.create(null); - -const cleanupExpiredTokens = function cleanupExpiredTokens () { - const now = Date.now() / 1000; - for (const jti of Object.keys(tokenBlacklist)) { - if (tokenBlacklist[jti].exp < now) { - delete tokenBlacklist[jti]; - } - } -}; - -// TODO: rework this function -const isTokenBlacklisted = function isTokenBlacklisted (token, tokenString) { - cleanupExpiredTokens(); - +const { insertTokenToBlacklist, findToken } = require('@sensebox/opensensemap-api-models/src/token'); +const hashJWT = require('./jwtRefreshTokenHasher'); + +// TODO: Move this to pg_cron +// const cleanupExpiredTokens = function cleanupExpiredTokens () { +// const now = Date.now() / 1000; +// for (const jti of Object.keys(tokenBlacklist)) { +// if (tokenBlacklist[jti].exp < now) { +// delete tokenBlacklist[jti]; +// } +// } +// }; + +const isTokenBlacklisted = async function isTokenBlacklisted (token, tokenString) { if (!token.jti) { // token has no id.. -> shouldn't be accepted return true; } const hash = hashJWT(tokenString); - if (typeof tokenBlacklist[hash] !== 'undefined') { + const blacklistedToken = await findToken(hash); + + if (blacklistedToken.length > 0) { return true; } @@ -34,26 +30,24 @@ const isTokenBlacklisted = function isTokenBlacklisted (token, tokenString) { }; const addTokenToBlacklist = function addTokenToBlacklist (token, tokenString) { - cleanupExpiredTokens(); - const hash = hashJWT(tokenString); if (token && token.jti) { - tokenBlacklist[hash] = token; + insertTokenToBlacklist(hash, token); } }; const addTokenHashToBlacklist = function addTokenHashToBlacklist (tokenHash) { - cleanupExpiredTokens(); - - if (typeof tokenHash === 'string') { - // just set the exp claim to now plus one week to be sure - tokenBlacklist[tokenHash] = { - exp: moment.utc() - .add(1, 'week') - .unix() - }; - } + // cleanupExpiredTokens(); + + // if (typeof tokenHash === 'string') { + // // just set the exp claim to now plus one week to be sure + // tokenBlacklist[tokenHash] = { + // exp: moment.utc() + // .add(1, 'week') + // .unix() + // }; + // } }; module.exports = { diff --git a/packages/models/migrations/0012_add_token_blacklist.sql b/packages/models/migrations/0012_add_token_blacklist.sql new file mode 100644 index 00000000..841d2c11 --- /dev/null +++ b/packages/models/migrations/0012_add_token_blacklist.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS "token_blacklist" ( + "hash" text NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp NOT NULL +); diff --git a/packages/models/migrations/meta/0012_snapshot.json b/packages/models/migrations/meta/0012_snapshot.json new file mode 100644 index 00000000..9bc7c30b --- /dev/null +++ b/packages/models/migrations/meta/0012_snapshot.json @@ -0,0 +1,758 @@ +{ + "id": "74a4430b-2bdc-4c7a-aa13-3f5bed6a6bd9", + "prevId": "54fd48ea-dce0-4ff8-b404-e58903008279", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spatial_index": { + "name": "spatial_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "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 + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + }, + "public.token_blacklist": { + "name": "token_blacklist", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "home_v2_lora", + "home_v2_ethernet", + "home_v2_ethernet_feinstaub", + "home_v2_wifi", + "home_v2_wifi_feinstaub", + "home_ethernet", + "home_wifi", + "home_ethernet_feinstaub", + "home_wifi_feinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "luftdaten_pms1003", + "luftdaten_pms1003_bme280", + "luftdaten_pms3003", + "luftdaten_pms3003_bme280", + "luftdaten_pms5003", + "luftdaten_pms5003_bme280", + "luftdaten_pms7003", + "luftdaten_pms7003_bme280", + "luftdaten_sps30_bme280", + "luftdaten_sps30_sht3x", + "hackair_home_v2", + "custom" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json index c0076c7d..3aa5971a 100644 --- a/packages/models/migrations/meta/_journal.json +++ b/packages/models/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1727774100701, "tag": "0011_drop_unused_columns_sensor", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1729843974723, + "tag": "0012_add_token_blacklist", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js index dbb35e8a..452377b2 100644 --- a/packages/models/schema/schema.js +++ b/packages/models/schema/schema.js @@ -165,6 +165,15 @@ const accessToken = pgTable('access_token', { token: text('token'), }); +const tokenBlacklist = pgTable('token_blacklist', { + hash: text('hash').notNull(), + token: text('token').notNull(), + expiresAt: timestamp('expires_at') + .notNull() + .$defaultFn(() => moment.utc().add(1, 'week') + .toDate()) +}); + const measurement = pgTable('measurement', { sensorId: text('sensor_id').notNull(), time: timestamp('time', { precision: 3, withTimezone: true }).defaultNow() @@ -203,6 +212,7 @@ const userRelations = relations(user, ({ one, many }) => ({ references: [profile.userId] }), devices: many(device), + // TODO: model shared devices sharedDevices: many(device), refreshToken: many(refreshToken) })); @@ -241,6 +251,7 @@ module.exports.passwordResetTable = passwordReset; module.exports.profileTable = profile; module.exports.profileImageTable = profileImage; module.exports.refreshTokenTable = refreshToken; +module.exports.tokenBlacklistTable = tokenBlacklist; module.exports.accessTokenRelations = accessTokenRelations; module.exports.deviceRelations = deviceRelations; module.exports.sensorRelations = sensorRelations; diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index 02ff97f4..1b77a7a6 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -5,8 +5,13 @@ const { deviceTable, sensorTable, accessTokenTable } = require('../../schema/sch const sensorLayouts = require('../box/sensorLayouts'); const { db } = require('../drizzle'); const ModelError = require('../modelError'); -const { inArray, arrayContains, sql, eq } = require('drizzle-orm'); +const { inArray, arrayContains, sql, eq, asc } = require('drizzle-orm'); const { insertMeasurement, insertMeasurements } = require('../measurement'); +const SketchTemplater = require('@sensebox/sketch-templater'); + +const { max_boxes: pagination_max_boxes } = require('config').get('openSenseMap-API-models.pagination'); + +const templateSketcher = new SketchTemplater(); const buildWhereClause = function buildWhereClause (opts = {}) { const { phenomenon, fromDate, toDate, bbox, near, maxDistance, grouptag } = opts; @@ -121,6 +126,18 @@ const findById = async function findById (deviceId, relations = {}) { return device; }; +const findDevicesByUserId = async function findDevicesByUserId (userId, opts = {}) { + const { page } = opts; + const devices = await db.query.deviceTable.findMany({ + where: (device, { eq }) => eq(device.userId, userId), + orderBy: (asc(deviceTable.createdAt)), + limit: pagination_max_boxes, + offset: pagination_max_boxes * page + }); + + return devices; +}; + const findDevices = async function findDevices (opts = {}, columns = {}) { const { name, limit } = opts; const devices = await db.query.deviceTable.findMany({ @@ -312,6 +329,44 @@ const updateDevice = async function updateDevice (deviceId, args) { return device[0]; }; +const generateSketch = function generateSketch (device, { + encoding, + serialPort, + soilDigitalPort, + soundMeterPort, + windSpeedPort, + ssid, + password, + devEUI, + appEUI, + appKey, + access_token, + display_enabled +} = {}) { + if (serialPort) { + device.serialPort = serialPort; + } + if (soilDigitalPort) { + device.soilDigitalPort = soilDigitalPort; + } + if (soundMeterPort) { + device.soundMeterPort = soundMeterPort; + } + if (windSpeedPort) { + device.windSpeedPort = windSpeedPort; + } + + device.ssid = ssid || ''; + device.password = password || ''; + device.devEUI = devEUI || ''; + device.appEUI = appEUI || ''; + device.appKey = appKey || ''; + device.access_token = access_token || ''; + device.display_enabled = display_enabled || ''; + + return templateSketcher.generateSketch(device, { encoding }); +}; + module.exports = { createDevice, updateDevice, @@ -319,8 +374,10 @@ module.exports = { findById, findDevices, findDevicesMinimal, + findDevicesByUserId, findTags, findAccessToken, saveMeasurement, - saveMeasurements + saveMeasurements, + generateSketch }; diff --git a/packages/models/src/password/index.js b/packages/models/src/password/index.js index df63fb98..65e49c1a 100644 --- a/packages/models/src/password/index.js +++ b/packages/models/src/password/index.js @@ -1,6 +1,15 @@ 'use strict'; +const moment = require('moment'); +const { v4: uuidv4 } = require('uuid'); +const { eq } = require('drizzle-orm'); + +const { passwordResetTable, passwordTable } = require('../../schema/schema'); const { db } = require('../drizzle'); +const ModelError = require('../modelError'); +const { validatePassword, passwordHasher } = require('./utils'); + +const { min_length: password_min_length } = require('config').get('openSenseMap-API-models.password'); const findByUserId = async function findByUserId (userId) { const password = await db.query.passwordTable.findFirst({ @@ -10,6 +19,72 @@ const findByUserId = async function findByUserId (userId) { return password; }; +const initPasswordReset = async function initPasswordReset ({ email }) { + const user = await db.query.userTable.findFirst({ + where: (user, { eq }) => eq(user.email, email.toLowerCase()) + }); + + if (!user) { + throw new ModelError('Password reset for this user not possible', { + type: 'ForbiddenError' + }); + } + + // Create entry with default values + await db + .insert(passwordResetTable) + .values({ userId: user.id }) + .onConflictDoUpdate({ + target: passwordResetTable.userId, + set: { + token: uuidv4(), + expiresAt: moment.utc().add(12, 'hours') + .toDate() + } + }); +}; + +const resetOldPassword = async function resetOldPassword ({ password, token }) { + const passwordReset = await db.query.passwordResetTable.findFirst({ + where: (reset, { eq }) => eq(reset.token, token) + }); + + if (!passwordReset) { + throw new ModelError('Password reset for this user not possible', { + type: 'ForbiddenError' + }); + } + + if (moment.utc().isAfter(moment.utc(passwordReset.expiresAt))) { + throw new ModelError('Password reset token expired', { + type: 'ForbiddenError' + }); + } + + // Validate new Password + if (validatePassword(password) === false) { + throw new ModelError( + `Password must be at least ${password_min_length} characters.` + ); + } + + // Update reset password + const hashedPassword = await passwordHasher(password); + await db + .update(passwordTable) + .set({ hash: hashedPassword }) + .where(eq(passwordTable.userId, passwordReset.userId)); + + // invalidate password reset token + await db + .delete(passwordResetTable) + .where(eq(passwordResetTable.token, token)); + + // TODO: invalidate refreshToken and active accessTokens +}; + module.exports = { - findByUserId + findByUserId, + initPasswordReset, + resetOldPassword }; diff --git a/packages/models/src/password/utils.js b/packages/models/src/password/utils.js index e20466bd..7dd50aa5 100644 --- a/packages/models/src/password/utils.js +++ b/packages/models/src/password/utils.js @@ -35,7 +35,15 @@ const validatePassword = function validatePassword (newPassword) { return newPassword.length >= Number(password_min_length); }; +const passwordHasher = function passwordHasher (plaintextPassword) { + return bcrypt.hash( + preparePasswordHash(plaintextPassword), + Number(password_salt_factor) + ); // signature generates a salt and hashes in one step +}; + module.exports = { checkPassword, - validatePassword + validatePassword, + passwordHasher }; diff --git a/packages/models/src/token/index.js b/packages/models/src/token/index.js new file mode 100644 index 00000000..14f7e963 --- /dev/null +++ b/packages/models/src/token/index.js @@ -0,0 +1,26 @@ +'use strict'; + +const moment = require('moment'); +const { eq } = require('drizzle-orm'); +const { tokenBlacklistTable } = require('../../schema/schema'); +const { db } = require('../drizzle'); + +const insertTokenToBlacklist = async function (hash, token) { + await db.insert(tokenBlacklistTable).values({ + hash, + token, + expiresAt: moment.unix(token.exp) + }); +}; + +const findToken = async function (hash) { + const blacklistedToken = await db.select().from(tokenBlacklistTable) + .where(eq(tokenBlacklistTable.hash, hash)); + + return blacklistedToken; +}; + +module.exports = { + findToken, + insertTokenToBlacklist +}; diff --git a/packages/models/src/user/index.js b/packages/models/src/user/index.js index ea1c6206..9902702f 100644 --- a/packages/models/src/user/index.js +++ b/packages/models/src/user/index.js @@ -1,32 +1,34 @@ 'use strict'; -const bcrypt = require('bcrypt'); -const crypto = require('crypto'); - const { userTable, passwordTable } = require('../../schema/schema'); const { db } = require('../drizzle'); const { createProfile } = require('../profile/profile'); const { eq } = require('drizzle-orm'); const ModelError = require('../modelError'); -const { checkPassword, validatePassword } = require('../password/utils'); - -// Configuration -const { min_length: password_min_length, salt_factor: password_salt_factor } = require('config').get('openSenseMap-API-models.password'); - -const preparePasswordHash = function preparePasswordHash (plaintextPassword) { - // first round: hash plaintextPassword with sha512 - const hash = crypto.createHash('sha512'); - hash.update(plaintextPassword.toString(), 'utf8'); - const hashed = hash.digest('base64'); // base64 for more entropy than hex +const { checkPassword, validatePassword, passwordHasher } = require('../password/utils'); - return hashed; +const findUserByNameOrEmail = async function findUserByNameOrEmail ( + emailOrName +) { + return db.query.userTable.findFirst({ + where: (user, { eq, or }) => + or(eq(user.email, emailOrName.toLowerCase()), eq(user.name, emailOrName)), + with: { + password: true + } + }); }; -const passwordHasher = function passwordHasher (plaintextPassword) { - return bcrypt.hash( - preparePasswordHash(plaintextPassword), - Number(password_salt_factor) - ); // signature generates a salt and hashes in one step +const findUserByEmailAndRole = async function findUserByEmailAndRole ({ + email, + role +}) { + const user = await db.query.userTable.findFirst({ + where: (user, { eq, and }) => + and(eq(user.email, email.toLowerCase(), eq(user.role, role))) + }); + + return user; }; const createUser = async function createUser (name, email, password, language) { @@ -131,6 +133,8 @@ const updateUser = async function updateUser ( }; module.exports = { + findUserByNameOrEmail, + findUserByEmailAndRole, createUser, deleteUser, updateUser diff --git a/packages/models/src/user/user.js b/packages/models/src/user/user.js index b6af182b..c3294890 100644 --- a/packages/models/src/user/user.js +++ b/packages/models/src/user/user.js @@ -26,7 +26,6 @@ const { mongoose } = require('../db'), ModelError = require('../modelError'), isemail = require('isemail'); -const { passwordTable, passwordResetTable } = require('../../schema/schema'); const { eq } = require('drizzle-orm'); const { findById, deleteDevice } = require('../device'); @@ -721,76 +720,6 @@ integrations.addToSchema(userSchema); const userModel = mongoose.model('User', userSchema); -const findUserByNameOrEmail = async function findUserByNameOrEmail (emailOrName) { - return db.query.userTable.findFirst({ - where: (user, { eq, or }) => or(eq(user.email, emailOrName.toLowerCase()), eq(user.name, emailOrName)), - with: { - password: true - } - }); -}; - -const initPasswordReset = async function initPasswordReset ({ email }) { - - const user = await db.query.userTable.findFirst({ - where: (user, { eq }) => eq(user.email, email.toLowerCase()) - }); - - if (!user) { - throw new ModelError('Password reset for this user not possible', { type: 'ForbiddenError' }); - } - - // Create entry with default values - await db.insert(passwordResetTable).values({ userId: user.id }) - .onConflictDoUpdate({ target: passwordResetTable.userId, set: { - token: uuidv4(), - expiresAt: moment.utc().add(12, 'hours') - .toDate() - } }); -}; - -const validatePassword = function validatePassword (newPassword) { - return newPassword.length >= Number(password_min_length); -}; - -const resetOldPassword = async function resetOldPassword ({ password, token }) { - const passwordReset = await db.query.passwordResetTable.findFirst({ - where: (reset, { eq }) => eq(reset.token, token) - }); - - if (!passwordReset) { - throw new ModelError('Password reset for this user not possible', { type: 'ForbiddenError' }); - } - - if (moment.utc().isAfter(moment.utc(passwordReset.expiresAt))) { - throw new ModelError('Password reset token expired', { type: 'ForbiddenError' }); - } - - // Validate new Password - if (validatePassword(password) === false) { - throw new ModelError(`Password must be at least ${password_min_length} characters.`); - } - - // Update reset password - const hashedPassword = await passwordHasher(password); - await db.update(passwordTable) - .set({ hash: hashedPassword }) - .where(eq(passwordTable.userId, passwordReset.userId)); - - // invalidate password reset token - await db.delete(passwordResetTable).where(eq(passwordResetTable.token, token)); - - // TODO: invalidate refreshToken and active accessTokens -}; - -const findUserByEmailAndRole = async function findUserByEmailAndRole ({ email, role }) { - const user = await db.query.userTable.findFirst({ - where: (user, { eq, and }) => and(eq(user.email, email.toLowerCase(), eq(user.role, role))) - }); - - return user; -}; - const checkDeviceOwner = async function checkDeviceOwner (userId, deviceId) { const device = await findById(deviceId); @@ -827,10 +756,6 @@ const removeDevice = async function removeDevice (deviceId) { module.exports = { schema: userSchema, model: userModel, - findUserByNameOrEmail, - findUserByEmailAndRole, - initPasswordReset, - resetOldPassword, removeDevice, checkDeviceOwner }; From a631251c2e3613e2d680d0f4a0ca1d9e0e6b6191 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Thu, 21 Nov 2024 14:21:20 +0100 Subject: [PATCH 17/34] more stuff --- .../api/lib/controllers/boxesController.js | 41 +- .../lib/controllers/measurementsController.js | 213 +++-- .../api/lib/controllers/usersController.js | 8 +- packages/api/lib/helpers/jwtHelpers.js | 9 +- packages/api/lib/routes.js | 2 +- .../0013_add_email_confirmation.sql | 1 + .../models/migrations/meta/0013_snapshot.json | 764 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 7 + packages/models/schema/schema.js | 1 + packages/models/src/device/index.js | 108 ++- packages/models/src/drizzle.js | 6 +- packages/models/src/measurement/index.js | 12 + packages/models/src/sensor/index.js | 48 +- packages/models/src/token/refresh.js | 14 +- packages/models/src/user/index.js | 75 +- 15 files changed, 1166 insertions(+), 143 deletions(-) create mode 100644 packages/models/migrations/0013_add_email_confirmation.sql create mode 100644 packages/models/migrations/meta/0013_snapshot.json diff --git a/packages/api/lib/controllers/boxesController.js b/packages/api/lib/controllers/boxesController.js index aa083f2e..9631655c 100644 --- a/packages/api/lib/controllers/boxesController.js +++ b/packages/api/lib/controllers/boxesController.js @@ -71,7 +71,7 @@ const handleError = require('../helpers/errorHandler'), jsonstringify = require('stringify-stream'); const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/box'); -const { createDevice, findDevices, findDevicesMinimal, findTags, updateDevice, findById, generateSketch } = require('@sensebox/opensensemap-api-models/src/device'); +const { createDevice, findDevices, findTags, updateDevice, findById, generateSketch } = require('@sensebox/opensensemap-api-models/src/device'); const { findByUserId } = require('@sensebox/opensensemap-api-models/src/password'); const { getSensorsWithLastMeasurement } = require('@sensebox/opensensemap-api-models/src/sensor'); const { removeDevice, checkPassword } = require('@sensebox/opensensemap-api-models/src/user/user'); @@ -315,19 +315,36 @@ const getBoxes = async function getBoxes (req, res) { } try { - let devices; + // let devices; // Search boxes by name // Directly return results and do nothing else - if (req._userParams.name) { - // stream = await Box.findBoxes(req._userParams); - devices = await findDevices(req._userParams, { id: true, name: true, location: true }); - } else if (req._userParams.minimal === 'true') { - devices = await findDevicesMinimal(req._userParams, { id: true, name: true, exposure: true, location: true, status: true }); - // stream = await Box.findBoxesMinimal(req._userParams); - } else { - // stream = await Box.findBoxesLastMeasurements(req._userParams); - } + // if (req._userParams.name) { + // // stream = await Box.findBoxes(req._userParams); + // devices = await findDevices(req._userParams, { id: true, name: true, location: true }); + // } else if (req._userParams.minimal === 'true') { + // devices = await findDevicesMinimal(req._userParams, { id: true, name: true, exposure: true, location: true, status: true }); + // // stream = await Box.findBoxesMinimal(req._userParams); + // } else { + // // stream = await Box.findBoxesLastMeasurements(req._userParams); + // devices = await findDevicesMinimal(req._userParams); + // } + + // if (req._userParams.minimal === 'true') { + // devices = await findDevices(req._userParams, { + // id: true, + // name: true, + // exposure: true, + // location: true, + // status: true + // }); + // // stream = await Box.findBoxesMinimal(req._userParams); + // } else { + // // stream = await Box.findBoxesLastMeasurements(req._userParams); + // devices = await findDevices(req._userParams); + // } + + const devices = await findDevices(req._userParams, {}, { sensors: { columns: { deviceId: false, sensorWikiType: false, sensorWikiPhenomenon: false, sensorWikiUnit: false } } }); // Deprecated: classify is performed by database // if (req._userParams.classify === 'true') { @@ -940,7 +957,7 @@ module.exports = { }, { name: 'full', defaultValue: 'false', allowedValues: ['true', 'false'] }, { predef: 'near' }, - { name: 'maxDistance' }, + { name: 'maxDistance', dataType: Number, defaultValue: 1000 }, { predef: 'bbox' } ]), parseAndValidateTimeParamsForFindAllBoxes, diff --git a/packages/api/lib/controllers/measurementsController.js b/packages/api/lib/controllers/measurementsController.js index b32e6b32..669f77fd 100644 --- a/packages/api/lib/controllers/measurementsController.js +++ b/packages/api/lib/controllers/measurementsController.js @@ -2,6 +2,7 @@ const { findAccessToken, findById, saveMeasurement, saveMeasurements } = require('@sensebox/opensensemap-api-models/src/device'); const { hasDecoder, decodeMeasurements } = require('@sensebox/opensensemap-api-models/src/measurement'); +const { getSensorsWithLastMeasurement, getSensorWithLastMeasurement, getSensorsWithLastMeasurements } = require('@sensebox/opensensemap-api-models/src/sensor'); const { BadRequestError, UnsupportedMediaTypeError, @@ -64,59 +65,79 @@ const { */ const getLatestMeasurements = async function getLatestMeasurements (req, res) { const { _userParams: params } = req; + const { boxId, sensorId, count, onlyValue } = req._userParams; - let box; + let device; try { - if (req._userParams.count) { - box = await Box.findBoxById(req._userParams.boxId, { - populate: false, - onlyLastMeasurements: false, - count: req._userParams.count, - projection: { - name: 1, - lastMeasurementAt: 1, - sensors: 1, - grouptag: 1 - } - }); - - const measurements = await Measurement.findLatestMeasurementsForSensorsWithCount(box, req._userParams.count); - for (let index = 0; index < box.sensors.length; index++) { - const sensor = box.sensors[index]; - const values = measurements.find(elem => elem._id.equals(sensor._id)); - sensor['lastMeasurements'] = values; - } - } else { - box = await Box.findBoxById(req._userParams.boxId, { - onlyLastMeasurements: true - }); - } - } catch (err) { - return handleError(err); - } - - if (params.sensorId) { - const sensor = box.sensors.find(s => s._id.equals(params.sensorId)); - if (sensor) { - if (params.onlyValue) { - if (!sensor.lastMeasurement) { - res.send(undefined); + // if (req._userParams.count) { + // box = await Box.findBoxById(req._userParams.boxId, { + // populate: false, + // onlyLastMeasurements: false, + // count: req._userParams.count, + // projection: { + // name: 1, + // lastMeasurementAt: 1, + // sensors: 1, + // grouptag: 1 + // } + // }); + + // const measurements = await Measurement.findLatestMeasurementsForSensorsWithCount(box, req._userParams.count); + // for (let index = 0; index < box.sensors.length; index++) { + // const sensor = box.sensors[index]; + // const values = measurements.find(elem => elem._id.equals(sensor._id)); + // sensor['lastMeasurements'] = values; + // } + // } else { + // box = await Box.findBoxById(req._userParams.boxId, { + // onlyLastMeasurements: true + // }); + device = await findById(boxId, { sensors: true }); + + if (sensorId) { + const sensor = device.sensors.find((s) => s.id === sensorId); + if (sensor) { + const sensorWithMeasurements = await getSensorWithLastMeasurement( + boxId, + sensorId, + count + ); + + if (onlyValue) { + if (!sensorWithMeasurements[0].lastMeasurement.value) { + res.send(undefined); + + return; + } + + res.send(sensorWithMeasurements[0].lastMeasurement.value); return; } - res.send(sensor.lastMeasurement.value); + + res.send(sensorWithMeasurements[0]); return; } - res.send(sensor); - return; + res.send( + new NotFoundError(`Sensor with id ${params.sensorId} does not exist`) + ); + + } else { + const sensorsWithMeasurements = await getSensorsWithLastMeasurements( + boxId, + count + ); + + device.sensors = sensorsWithMeasurements; } - res.send(new NotFoundError(`Sensor with id ${params.sensorId} does not exist`)); - } else { - res.send(box); + // } + res.send(device); + } catch (err) { + return handleError(err); } }; @@ -237,7 +258,7 @@ const getData = async function getData (req, res) { * @apiParam {Boolean=true,false} [download=true] Set the `content-disposition` header to force browsers to download instead of displaying. */ const getDataMulti = async function getDataMulti (req, res) { - const { boxId, bbox, exposure, delimiter, columns, fromDate, toDate, phenomenon, download, format } = req._userParams; + const { boxId, bbox, exposure, grouptag, delimiter, columns, fromDate, toDate, phenomenon, download, format } = req._userParams; // build query const queryParams = { @@ -259,6 +280,11 @@ const getDataMulti = async function getDataMulti (req, res) { queryParams['exposure'] = { '$in': exposure }; } + // grouptag parameter + if (grouptag) { + // TODO + } + try { let stream = await Box.findMeasurementsOfBoxesStream({ query: queryParams, @@ -308,37 +334,37 @@ const getDataMulti = async function getDataMulti (req, res) { * @apiParam {String} grouptag The grouptag to search by. * @apiParam {String=json} format=json */ -const getDataByGroupTag = async function getDataByGroupTag (req, res) { - const { grouptag, format } = req._userParams; - const queryTags = grouptag.split(','); - // build query - const queryParams = {}; - if (grouptag) { - queryParams['grouptag'] = { '$all': queryTags }; - } - - try { - let stream = await Box.findMeasurementsOfBoxesByTagStream({ - query: queryParams - }); - stream = stream - .on('error', function (err) { - return handleError(err); - }); - switch (format) { - case 'json': - res.header('Content-Type', 'application/json'); - stream = stream - .pipe(jsonstringify({ open: '[', close: ']' })); - break; - } - - stream - .pipe(res); - } catch (err) { - return handleError(err); - } -}; +// const getDataByGroupTag = async function getDataByGroupTag (req, res) { +// const { grouptag, format } = req._userParams; +// const queryTags = grouptag.split(','); +// // build query +// const queryParams = {}; +// if (grouptag) { +// queryParams['grouptag'] = { '$all': queryTags }; +// } + +// try { +// let stream = await Box.findMeasurementsOfBoxesByTagStream({ +// query: queryParams +// }); +// stream = stream +// .on('error', function (err) { +// return handleError(err); +// }); +// switch (format) { +// case 'json': +// res.header('Content-Type', 'application/json'); +// stream = stream +// .pipe(jsonstringify({ open: '[', close: ']' })); +// break; +// } + +// stream +// .pipe(res); +// } catch (err) { +// return handleError(err); +// } +// }; /** * @api {post} /boxes/:senseBoxId/:sensorId Post new measurement @@ -519,10 +545,21 @@ module.exports = { retrieveParameters([ { predef: 'sensorId', required: true }, { name: 'format', defaultValue: 'json', allowedValues: ['json', 'csv'] }, - { name: 'download', defaultValue: 'false', allowedValues: ['true', 'false'] }, + { + name: 'download', + defaultValue: 'false', + allowedValues: ['true', 'false'] + }, { predef: 'delimiter' }, { name: 'outliers', allowedValues: ['mark', 'replace'] }, - { name: 'outlierWindow', dataType: 'Integer', aliases: ['outlier-window'], defaultValue: 15, min: 1, max: 50 }, + { + name: 'outlierWindow', + dataType: 'Integer', + aliases: ['outlier-window'], + defaultValue: 15, + min: 1, + max: 50 + }, { predef: 'toDate' }, { predef: 'fromDate' } ]), @@ -531,27 +568,33 @@ module.exports = { ], getDataMulti: [ retrieveParameters([ - { name: 'boxId', aliases: ['senseboxid', 'senseboxids', 'boxid', 'boxids'], dataType: ['id'] }, + { + name: 'boxId', + aliases: ['senseboxid', 'senseboxids', 'boxid', 'boxids'], + dataType: ['id'] + }, { name: 'phenomenon', required: true }, + { name: 'grouptag' }, { predef: 'delimiter' }, - { name: 'exposure', allowedValues: Box.BOX_VALID_EXPOSURES, dataType: ['String'] }, + { + name: 'exposure', + allowedValues: Box.BOX_VALID_EXPOSURES, + dataType: ['String'] + }, { predef: 'columnsGetDataMulti' }, { predef: 'bbox' }, { predef: 'toDate' }, { predef: 'fromDate' }, - { name: 'download', defaultValue: 'true', allowedValues: ['true', 'false'] }, + { + name: 'download', + defaultValue: 'true', + allowedValues: ['true', 'false'] + }, { name: 'format', defaultValue: 'csv', allowedValues: ['csv', 'json'] } ]), validateFromToTimeParams, getDataMulti ], - getDataByGroupTag: [ - retrieveParameters([ - { name: 'grouptag', required: true }, - { name: 'format', defaultValue: 'json', allowedValues: ['json'] } - ]), - getDataByGroupTag - ], getLatestMeasurements: [ retrieveParameters([ { predef: 'boxId', required: true }, diff --git a/packages/api/lib/controllers/usersController.js b/packages/api/lib/controllers/usersController.js index 2b5667e3..1cf10e9d 100644 --- a/packages/api/lib/controllers/usersController.js +++ b/packages/api/lib/controllers/usersController.js @@ -19,7 +19,7 @@ const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/bo const { findDevices, findDevicesByUserId } = require('@sensebox/opensensemap-api-models/src/device'); const { initPasswordReset, resetOldPassword } = require('@sensebox/opensensemap-api-models/src/password'); const { checkPassword } = require('@sensebox/opensensemap-api-models/src/password/utils'); -const { createUser, findUserByNameOrEmail } = require('@sensebox/opensensemap-api-models/src/user'); +const { createUser, findUserByNameOrEmail, resendEmailConfirmation, confirmEmail } = require('@sensebox/opensensemap-api-models/src/user'); /** * define for nested user parameter for box creation request @@ -236,7 +236,8 @@ const resetPassword = async function resetPassword (req, res) { */ const confirmEmailAddress = async function confirmEmailAddress (req, res) { try { - await User.confirmEmail(req._userParams); + await confirmEmail(req._userParams); + // await User.confirmEmail(req._userParams); res.send(200, { code: 'Ok', message: 'E-Mail successfully confirmed. Thank you', @@ -388,7 +389,8 @@ const requestEmailConfirmation = async function requestEmailConfirmation ( res ) { try { - const result = await req.user.resendEmailConfirmation(); + // const result = await req.user.resendEmailConfirmation(); + const result = await resendEmailConfirmation(req.user); let usedAddress = result.email; if (result.unconfirmedEmail) { usedAddress = result.unconfirmedEmail; diff --git a/packages/api/lib/helpers/jwtHelpers.js b/packages/api/lib/helpers/jwtHelpers.js index 7655e863..30e79c30 100644 --- a/packages/api/lib/helpers/jwtHelpers.js +++ b/packages/api/lib/helpers/jwtHelpers.js @@ -1,6 +1,6 @@ 'use strict'; -const { addRefreshToken, deleteRefreshToken } = require('@sensebox/opensensemap-api-models/src/token/refresh'); +const { addRefreshToken, deleteRefreshToken, findRefreshToken, findRefreshTokenUser } = require('@sensebox/opensensemap-api-models/src/token/refresh'); const { findUserByEmailAndRole } = require('@sensebox/opensensemap-api-models/src/user'); const config = require('config'), jwt = require('jsonwebtoken'), @@ -61,14 +61,15 @@ const invalidateToken = async function invalidateToken ({ user, _jwt, _jwtString }; const refreshJwt = async function refreshJwt (refreshToken) { - const user = await User.findOne({ refreshToken, refreshTokenExpires: { $gte: moment.utc().toDate() } }); + // const user = await User.findOne({ refreshToken, refreshTokenExpires: { $gte: moment.utc().toDate() } }); + const user = await findRefreshTokenUser(refreshToken); if (!user) { throw new ForbiddenError('Refresh token invalid or too old. Please sign in with your username and password.'); } - // invalidate old token - addTokenHashToBlacklist(refreshToken); + // TODO: invalidate old token + // addTokenHashToBlacklist(refreshToken); const { token, refreshToken: newRefreshToken } = await createToken(user); diff --git a/packages/api/lib/routes.js b/packages/api/lib/routes.js index a8a9e5c7..c2e3a9a5 100644 --- a/packages/api/lib/routes.js +++ b/packages/api/lib/routes.js @@ -80,7 +80,7 @@ const routes = { { path: `${statisticsPath}/descriptive`, method: 'get', handler: statisticsController.descriptiveStatisticsHandler, reference: 'api-Statistics-descriptive' }, { path: `${boxesPath}`, method: 'get', handler: boxesController.getBoxes, reference: 'api-Boxes-getBoxes' }, { path: `${boxesPath}/data`, method: 'get', handler: measurementsController.getDataMulti, reference: 'api-Measurements-getDataMulti' }, - { path: `${boxesPath}/data/bytag`, method: 'get', handler: measurementsController.getDataByGroupTag, reference: 'api-Measurements-getDataByGroupTag' }, + // { path: `${boxesPath}/data/bytag`, method: 'get', handler: measurementsController.getDataByGroupTag, reference: 'api-Measurements-getDataByGroupTag' }, { path: `${boxesPath}/:boxId`, method: 'get', handler: boxesController.getBox, reference: 'api-Boxes-getBox' }, { path: `${boxesPath}/:boxId/sensors`, method: 'get', handler: measurementsController.getLatestMeasurements, reference: 'api-Measurements-getLatestMeasurements' }, { path: `${boxesPath}/:boxId/sensors/:sensorId`, method: 'get', handler: measurementsController.getLatestMeasurements, reference: 'api-Measurements-getLatestMeasurementOfSensor' }, diff --git a/packages/models/migrations/0013_add_email_confirmation.sql b/packages/models/migrations/0013_add_email_confirmation.sql new file mode 100644 index 00000000..9f12da8a --- /dev/null +++ b/packages/models/migrations/0013_add_email_confirmation.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "email_confirmation_token" text; \ No newline at end of file diff --git a/packages/models/migrations/meta/0013_snapshot.json b/packages/models/migrations/meta/0013_snapshot.json new file mode 100644 index 00000000..7aafd8e2 --- /dev/null +++ b/packages/models/migrations/meta/0013_snapshot.json @@ -0,0 +1,764 @@ +{ + "id": "0194822c-5bf7-43e5-b024-3691b097874f", + "prevId": "74a4430b-2bdc-4c7a-aa13-3f5bed6a6bd9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spatial_index": { + "name": "spatial_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "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 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + }, + "public.token_blacklist": { + "name": "token_blacklist", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "home_v2_lora", + "home_v2_ethernet", + "home_v2_ethernet_feinstaub", + "home_v2_wifi", + "home_v2_wifi_feinstaub", + "home_ethernet", + "home_wifi", + "home_ethernet_feinstaub", + "home_wifi_feinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "luftdaten_pms1003", + "luftdaten_pms1003_bme280", + "luftdaten_pms3003", + "luftdaten_pms3003_bme280", + "luftdaten_pms5003", + "luftdaten_pms5003_bme280", + "luftdaten_pms7003", + "luftdaten_pms7003_bme280", + "luftdaten_sps30_bme280", + "luftdaten_sps30_sht3x", + "hackair_home_v2", + "custom" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json index 3aa5971a..5c55c140 100644 --- a/packages/models/migrations/meta/_journal.json +++ b/packages/models/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1729843974723, "tag": "0012_add_token_blacklist", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1729860468725, + "tag": "0013_add_email_confirmation", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js index 452377b2..21e6c351 100644 --- a/packages/models/schema/schema.js +++ b/packages/models/schema/schema.js @@ -74,6 +74,7 @@ const user = pgTable('user', { role: text('role', { enum: ['admin', 'user'] }).default('user'), language: text('language').default('en_US'), emailIsConfirmed: boolean('email_is_confirmed').default(false), + emailConfirmationToken: text('email_confirmation_token').$defaultFn(() => uuidv4()), createdAt: timestamp('created_at').defaultNow() .notNull(), updatedAt: timestamp('updated_at').defaultNow() diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index 1b77a7a6..7ada1a40 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -5,7 +5,7 @@ const { deviceTable, sensorTable, accessTokenTable } = require('../../schema/sch const sensorLayouts = require('../box/sensorLayouts'); const { db } = require('../drizzle'); const ModelError = require('../modelError'); -const { inArray, arrayContains, sql, eq, asc } = require('drizzle-orm'); +const { inArray, arrayContains, sql, eq, asc, ilike, getTableColumns } = require('drizzle-orm'); const { insertMeasurement, insertMeasurements } = require('../measurement'); const SketchTemplater = require('@sensebox/sketch-templater'); @@ -14,8 +14,19 @@ const { max_boxes: pagination_max_boxes } = require('config').get('openSenseMap- const templateSketcher = new SketchTemplater(); const buildWhereClause = function buildWhereClause (opts = {}) { - const { phenomenon, fromDate, toDate, bbox, near, maxDistance, grouptag } = opts; + const { name, phenomenon, fromDate, toDate, bbox, near, maxDistance, grouptag } = opts; const clause = []; + const columns = {}; + + if (name) { + clause.push(ilike(deviceTable['name'], `%${name}%`)); + } + + if (phenomenon) { + columns['sensors'] = { + where: (sensor, { ilike }) => ilike(sensorTable['title'], `%${phenomenon}%`) + }; + } // simple string parameters for (const param of ['exposure', 'model']) { @@ -30,11 +41,15 @@ const buildWhereClause = function buildWhereClause (opts = {}) { // https://orm.drizzle.team/learn/guides/postgis-geometry-point if (bbox) { - // TODO: checkout postgis bbox queries + const [latSW, lngSW] = bbox.coordinates[0][0]; + const [latNE, lngNE] = bbox.coordinates[0][2]; + clause.push( + sql`st_within(${deviceTable['location']}, st_makeenvelope(${lngSW}, ${latSW}, ${lngNE}, ${latNE}, 4326))` + ); } if (near) { - // TODO: implement + clause.push(sql`st_dwithin(${deviceTable['location']}, ST_SetSRID(ST_MakePoint(${near[1]}, ${near[0]}), 4326), ${maxDistance})`); } if (fromDate || toDate) { @@ -43,7 +58,10 @@ const buildWhereClause = function buildWhereClause (opts = {}) { } } - return clause; + return { + includeColumns: columns, + whereClause: clause + }; }; const createDevice = async function createDevice (userId, params) { @@ -77,18 +95,20 @@ const createDevice = async function createDevice (userId, params) { } // TODO: handle in transaction - const [device] = await db.insert(deviceTable).values({ - userId, - name, - exposure, - description, - latitude: location[1], - longitude: location[0], - location: { x: location[1], y: location[0] }, - useAuth, - model, - tags: grouptag - }) + const [device] = await db + .insert(deviceTable) + .values({ + userId, + name, + exposure, + description, + latitude: location[1], + longitude: location[0], + location: sql`ST_SetSRID(ST_MakePoint(${location[1]}, ${location[0]}), 4326)`, + useAuth, + model, + tags: grouptag + }) .returning(); const [accessToken] = await db.insert(accessTokenTable).values({ @@ -119,6 +139,9 @@ const deleteDevice = async function (filter) { const findById = async function findById (deviceId, relations = {}) { const device = await db.query.deviceTable.findFirst({ + columns: { + ...DEFAULT_COLUMNS + }, where: (device, { eq }) => eq(device.id, deviceId), ...(Object.keys(relations).length !== 0 && { with: relations }) }); @@ -138,23 +161,27 @@ const findDevicesByUserId = async function findDevicesByUserId (userId, opts = { return devices; }; -const findDevices = async function findDevices (opts = {}, columns = {}) { - const { name, limit } = opts; - const devices = await db.query.deviceTable.findMany({ - ...(Object.keys(columns).length !== 0 && { columns }), - where: (device, { ilike }) => ilike(device.name, `%${name}%`), - limit - }); +const findDevices = async function findDevices ( + opts = {}, + columns = {}, + relations = {} +) { + const { minimal, limit } = opts; + const { includeColumns, whereClause } = buildWhereClause(opts); - return devices; -}; + columns = (minimal === 'true') ? MINIMAL_COLUMNS : { ...DEFAULT_COLUMNS, ...columns }; -// TODO: merge with findDevices -const findDevicesMinimal = async function findDevicesMinimal (opts = {}, columns = {}) { - const whereClause = buildWhereClause(opts); + relations = { + ...relations, + ...includeColumns + }; const devices = await db.query.deviceTable.findMany({ ...(Object.keys(columns).length !== 0 && { columns }), - ...(Object.keys(whereClause).length !== 0 && { where: (_, { and }) => and(...whereClause) }) + ...(Object.keys(relations).length !== 0 && { with: relations }), + ...(Object.keys(whereClause).length !== 0 && { + where: (_, { and }) => and(...whereClause) + }), + limit }); return devices; @@ -367,13 +394,32 @@ const generateSketch = function generateSketch (device, { return templateSketcher.generateSketch(device, { encoding }); }; +const MINIMAL_COLUMNS = { + id: true, + name: true, + exposure: true, + location: true +}; + +const DEFAULT_COLUMNS = { + id: true, + name: true, + model: true, + exposure: true, + grouptag: true, + image: true, + description: true, + link: true, + createdAt: true, + updatedAt: true, +}; + module.exports = { createDevice, updateDevice, deleteDevice, findById, findDevices, - findDevicesMinimal, findDevicesByUserId, findTags, findAccessToken, diff --git a/packages/models/src/drizzle.js b/packages/models/src/drizzle.js index d6d75fd3..14e91046 100644 --- a/packages/models/src/drizzle.js +++ b/packages/models/src/drizzle.js @@ -16,6 +16,8 @@ const { profileRelations, accessTokenRelations, accessTokenTable, + refreshTokenRelations, + refreshTokenTable, measurementTable } = require('../schema/schema'); @@ -26,6 +28,7 @@ const pool = new Pool({ const schema = { accessTokenTable, + refreshTokenTable, deviceTable, sensorTable, userTable, @@ -38,7 +41,8 @@ const schema = { sensorRelations, userRelations, profileRelations, - accessTokenRelations + accessTokenRelations, + refreshTokenRelations }; const db = drizzle(pool, { diff --git a/packages/models/src/measurement/index.js b/packages/models/src/measurement/index.js index 6fc74554..09cfdb75 100644 --- a/packages/models/src/measurement/index.js +++ b/packages/models/src/measurement/index.js @@ -1,5 +1,6 @@ 'use strict'; +const { desc } = require('drizzle-orm'); const { measurementTable } = require('../../schema/schema'); const { db } = require('../drizzle'); const ModelError = require('../modelError'); @@ -28,6 +29,16 @@ const insertMeasurement = async function insertMeasurement (measurement) { }); }; +const getMeasurements = async function getMeasurements (sensorId, limit = 1) { + const measurements = await db.query.measurementTable.findMany({ + where: (measurement, { eq }) => eq(measurement.sensorId, sensorId), + orderBy: desc(measurementTable.time), + limit: limit, + }); + + return measurements; +}; + const insertMeasurements = async function insertMeasurements (measurements) { return db.insert(measurementTable).values(measurements); }; @@ -35,6 +46,7 @@ const insertMeasurements = async function insertMeasurements (measurements) { module.exports = { decodeMeasurements, hasDecoder, + getMeasurements, insertMeasurement, insertMeasurements }; diff --git a/packages/models/src/sensor/index.js b/packages/models/src/sensor/index.js index 8de157d1..e2fb3c60 100644 --- a/packages/models/src/sensor/index.js +++ b/packages/models/src/sensor/index.js @@ -4,16 +4,16 @@ const { sql } = require('drizzle-orm'); const { db } = require('../drizzle'); // LATERAL JOIN to get latest measurement for sensors belonging to a specific device, including device name -const getSensorsWithLastMeasurement = async function getSensorsWithLastMeasurement (deviceId) { +const getSensorsWithLastMeasurement = async function getSensorsWithLastMeasurement (deviceId, count = 1) { const { rows } = await db.execute( - sql`SELECT s.title, s.unit, s.sensor_type, json_object(ARRAY['value', 'createdAt'], ARRAY[CAST(measure.value AS TEXT),CAST(measure.time AS TEXT)]) AS "lastMeasurement" + sql`SELECT s.id, s.title, s.unit, s.sensor_type, json_object(ARRAY['value', 'createdAt'], ARRAY[CAST(measure.value AS TEXT),CAST(measure.time AS TEXT)]) AS "lastMeasurement" FROM sensor s JOIN device d ON s.device_id = d.id LEFT JOIN LATERAL ( SELECT * FROM measurement m WHERE m.sensor_id = s.id ORDER BY m.time DESC - LIMIT 1 + LIMIT ${count} ) AS measure ON true WHERE s.device_id = ${deviceId};`, ); @@ -21,6 +21,46 @@ const getSensorsWithLastMeasurement = async function getSensorsWithLastMeasureme return rows; }; +const getSensorsWithLastMeasurements = + async function getSensorsWithLastMeasurements (deviceId, count = 1) { + const { rows } = await db.execute( + sql`SELECT s.id, s.title, s.unit, s.sensor_type, json_agg(json_build_object('value', measure.value, 'createdAt', measure.time)) AS "lastMeasurements" + FROM sensor s + JOIN device d ON s.device_id = d.id + LEFT JOIN LATERAL ( + SELECT * FROM measurement m + WHERE m.sensor_id = s.id + ORDER BY m.time DESC + LIMIT ${count} + ) AS measure ON true + WHERE s.device_id = ${deviceId} + GROUP BY s.id;` + ); + + return rows; + }; + +// LATERAL JOIN to get latest measurement for sensors belonging to a specific device, including device name +const getSensorWithLastMeasurement = + async function getSensorWithLastMeasurement (deviceId, sensorId, count = 1) { + const { rows } = await db.execute( + sql`SELECT s.id, s.title, s.unit, s.sensor_type, json_object(ARRAY['value', 'createdAt'], ARRAY[CAST(measure.value AS TEXT),CAST(measure.time AS TEXT)]) AS "lastMeasurement" + FROM sensor s + JOIN device d ON s.device_id = d.id + LEFT JOIN LATERAL ( + SELECT * FROM measurement m + WHERE m.sensor_id = s.id + ORDER BY m.time DESC + LIMIT ${count} + ) AS measure ON true + WHERE s.device_id = ${deviceId} AND s.id=${sensorId};` + ); + + return rows; + }; + module.exports = { - getSensorsWithLastMeasurement + getSensorsWithLastMeasurement, + getSensorsWithLastMeasurements, + getSensorWithLastMeasurement }; diff --git a/packages/models/src/token/refresh.js b/packages/models/src/token/refresh.js index 463df796..d69649ae 100644 --- a/packages/models/src/token/refresh.js +++ b/packages/models/src/token/refresh.js @@ -16,7 +16,19 @@ const deleteRefreshToken = async function deleteRefreshToken (hash) { await db.delete(refreshTokenTable).where(eq(refreshTokenTable.token, hash)); }; +const findRefreshTokenUser = async function findRefreshTokenUser (token) { + const token1 = await db.query.refreshTokenTable.findFirst({ + where: (refreshToken, { eq }) => eq(refreshToken.token, token), + with: { + user: true + } + }); + + return token1.user; +}; + module.exports = { addRefreshToken, - deleteRefreshToken + deleteRefreshToken, + findRefreshTokenUser }; diff --git a/packages/models/src/user/index.js b/packages/models/src/user/index.js index 9902702f..d97f0ba5 100644 --- a/packages/models/src/user/index.js +++ b/packages/models/src/user/index.js @@ -1,5 +1,7 @@ 'use strict'; +const { v4: uuidv4 } = require('uuid'); + const { userTable, passwordTable } = require('../../schema/schema'); const { db } = require('../drizzle'); const { createProfile } = require('../profile/profile'); @@ -132,10 +134,81 @@ const updateUser = async function updateUser ( return user[0]; }; +const confirmEmail = async function confirmEmail ({ token, email }) { + + const user = await findUserByNameOrEmail(email); + + if (!user) { + throw new ModelError('invalid email confirmation token', { + type: 'ForbiddenError' + }); + } + + const updatedUser = await db.update(userTable).set({ + emailIsConfirmed: true, + emailConfirmationToken: null + }) + .where() + .returning(); + + return updatedUser[0]; + + // return this.findOne({ + // $and: [ + // { $or: [{ email: email }, { unconfirmedEmail: email }] }, + // { emailConfirmationToken: token } + // ] + // }) + // .exec() + // .then(function (user) { + // if (!user) { + // throw new ModelError('invalid email confirmation token', { + // type: 'ForbiddenError' + // }); + // } + + // // set email to email address from request + // user.set('email', email); + + // // mark user as confirmed + // user.set('emailConfirmationToken', undefined); + // user.set('emailIsConfirmed', true); + // user.set('unconfirmedEmail', undefined); + + // return user.save(); + // }); +}; + +const resendEmailConfirmation = async function resendEmailConfirmation (user) { + if (user.emailIsConfirmed === true) { + return Promise.reject( + new ModelError(`Email address ${user.email} is already confirmed.`, { + type: 'UnprocessableEntityError' + }) + ); + } + + const savedUser = await db.update(userTable).set({ + emailConfirmationToken: uuidv4() + }) + .returning(); + + return savedUser[0]; + // user.set('emailConfirmationToken', uuidv4()); + + // return user.save().then(function (savedUser) { + // savedUser.mail('resendEmailConfirmation'); + + // return savedUser; + // }); +}; + module.exports = { findUserByNameOrEmail, findUserByEmailAndRole, createUser, deleteUser, - updateUser + updateUser, + confirmEmail, + resendEmailConfirmation }; From 3c3ff2fb6f1429cb396b0c72e46b06d6022fbabc Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Wed, 18 Dec 2024 15:21:58 +0100 Subject: [PATCH 18/34] read connection string from env --- packages/models/src/drizzle.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/models/src/drizzle.js b/packages/models/src/drizzle.js index 14e91046..d37f0e36 100644 --- a/packages/models/src/drizzle.js +++ b/packages/models/src/drizzle.js @@ -1,5 +1,7 @@ 'use strict'; +const config = require('config').get('openSenseMap-API-models.db'); + const { drizzle } = require('drizzle-orm/node-postgres'); const { Pool } = require('pg'); const { @@ -21,9 +23,26 @@ const { measurementTable } = require('../schema/schema'); +const getDBUri = function getDBUri (uri) { + // if available, use user specified db connection uri + if (uri) { + return uri; + } + + // get uri from config + uri = config.get('database_url'); + if (uri) { + return uri; + } + + // otherwise build uri from config supplied values + const { user, userpass, host, port, db } = config; + + return `postgresql://${user}:${userpass}@${host}:${port}/${db}`; +}; const pool = new Pool({ - connectionString: 'postgresql://postgres:postgres@localhost:5432/opensensemap' + connectionString: getDBUri() }); const schema = { From d83fc93ce5b509c2465106bff3c2ecc50ff70c43 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Thu, 19 Dec 2024 12:18:59 +0100 Subject: [PATCH 19/34] update schema --- .../0014_update_tables_and_relations.sql | 38 + .../models/migrations/meta/0014_snapshot.json | 911 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 7 + packages/models/schema/enum.js | 21 +- packages/models/schema/schema.js | 137 ++- packages/models/src/drizzle.js | 21 +- 6 files changed, 1102 insertions(+), 33 deletions(-) create mode 100644 packages/models/migrations/0014_update_tables_and_relations.sql create mode 100644 packages/models/migrations/meta/0014_snapshot.json diff --git a/packages/models/migrations/0014_update_tables_and_relations.sql b/packages/models/migrations/0014_update_tables_and_relations.sql new file mode 100644 index 00000000..a5cd98e3 --- /dev/null +++ b/packages/models/migrations/0014_update_tables_and_relations.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS "location" ( + "id" serial PRIMARY KEY NOT NULL, + "location" geometry(point) NOT NULL, + CONSTRAINT "location_location_unique" UNIQUE("location") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "device_to_location" ( + "device_id" text NOT NULL, + "location_id" integer NOT NULL, + "time" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "device_to_location_device_id_location_id_time_pk" PRIMARY KEY("device_id","location_id","time"), + CONSTRAINT "device_to_location_device_id_location_id_time_unique" UNIQUE("device_id","location_id","time") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "log_entry" ( + "id" text PRIMARY KEY NOT NULL, + "content" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "public" boolean DEFAULT false NOT NULL, + "device_id" text NOT NULL +); +--> statement-breakpoint +DROP INDEX IF EXISTS "spatial_index";--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "expires_at" date;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "device_to_location" ADD CONSTRAINT "device_to_location_device_id_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."device"("id") ON DELETE cascade ON UPDATE cascade; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "device_to_location" ADD CONSTRAINT "device_to_location_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "location_index" ON "location" USING gist ("location");--> statement-breakpoint +ALTER TABLE "device" DROP COLUMN IF EXISTS "location"; \ No newline at end of file diff --git a/packages/models/migrations/meta/0014_snapshot.json b/packages/models/migrations/meta/0014_snapshot.json new file mode 100644 index 00000000..bc87fed5 --- /dev/null +++ b/packages/models/migrations/meta/0014_snapshot.json @@ -0,0 +1,911 @@ +{ + "id": "6ba30027-0bcb-421c-8e58-5777d52f3422", + "prevId": "0194822c-5bf7-43e5-b024-3691b097874f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "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 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + }, + "public.token_blacklist": { + "name": "token_blacklist", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "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" + ] + } + } + }, + "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": "integer", + "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" + ] + } + } + }, + "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": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "home_v2_lora", + "home_v2_ethernet", + "home_v2_ethernet_feinstaub", + "home_v2_wifi", + "home_v2_wifi_feinstaub", + "home_ethernet", + "home_wifi", + "home_ethernet_feinstaub", + "home_wifi_feinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "luftdaten_pms1003", + "luftdaten_pms1003_bme280", + "luftdaten_pms3003", + "luftdaten_pms3003_bme280", + "luftdaten_pms5003", + "luftdaten_pms5003_bme280", + "luftdaten_pms7003", + "luftdaten_pms7003_bme280", + "luftdaten_sps30_bme280", + "luftdaten_sps30_sht3x", + "hackair_home_v2", + "custom" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json index 5c55c140..7e4902b9 100644 --- a/packages/models/migrations/meta/_journal.json +++ b/packages/models/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1729860468725, "tag": "0013_add_email_confirmation", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1734606789875, + "tag": "0014_update_tables_and_relations", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/models/schema/enum.js b/packages/models/schema/enum.js index 0b4fe53b..5c1f92b4 100644 --- a/packages/models/schema/enum.js +++ b/packages/models/schema/enum.js @@ -2,7 +2,7 @@ const { pgEnum } = require('drizzle-orm/pg-core'); -const deviceModel = pgEnum('model', [ +const DeviceModelEnum = pgEnum('model', [ 'home_v2_lora', 'home_v2_ethernet', 'home_v2_ethernet_feinstaub', @@ -28,24 +28,21 @@ const deviceModel = pgEnum('model', [ 'luftdaten_sps30_bme280', 'luftdaten_sps30_sht3x', 'hackair_home_v2', - 'custom', + 'custom' ]); -const exposure = pgEnum('exposure', [ +// Enum for device exposure types +const DeviceExposureEnum = pgEnum('exposure', [ 'indoor', 'outdoor', 'mobile', - 'unknown' + 'unknown', ]); -const status = pgEnum('status', [ - 'active', - 'inactive', - 'old' -]); +const DeviceStatusEnum = pgEnum('status', ['active', 'inactive', 'old']); module.exports = { - deviceModel, - exposure, - status + DeviceModelEnum, + DeviceExposureEnum, + DeviceStatusEnum }; diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js index 21e6c351..d79bd5f6 100644 --- a/packages/models/schema/schema.js +++ b/packages/models/schema/schema.js @@ -5,40 +5,66 @@ const { relations, sql } = require('drizzle-orm'); const { createId } = require('@paralleldrive/cuid2'); const { v4: uuidv4 } = require('uuid'); const moment = require('moment'); -const { exposure, status, deviceModel } = require('./enum'); +const { DeviceExposureEnum, DeviceStatusEnum, DeviceModelEnum } = require('./enum'); const { bytea } = require('./types'); +const { date } = require('drizzle-orm/pg-core'); +const { integer } = require('drizzle-orm/pg-core'); +const { primaryKey } = require('drizzle-orm/pg-core'); +const { serial } = require('drizzle-orm/pg-core'); /** * Table definition */ const device = pgTable('device', { - id: text('id').primaryKey() + id: text('id') + .primaryKey() .notNull() .$defaultFn(() => createId()), name: text('name').notNull(), image: text('image'), description: text('description'), + tags: text('tags') + .array() + .default(sql`ARRAY[]::text[]`), link: text('link'), useAuth: boolean('use_auth'), - exposure: exposure('exposure'), - status: status('status').default('inactive'), - model: deviceModel('model'), + exposure: DeviceExposureEnum('exposure'), + status: DeviceStatusEnum('status').default('inactive'), + model: DeviceModelEnum('model'), public: boolean('public').default(false), createdAt: timestamp('created_at').defaultNow() .notNull(), updatedAt: timestamp('updated_at').defaultNow() - .notNull() - .$onUpdateFn(() => new Date()), + .notNull(), + expiresAt: date('expires_at', { mode: 'date' }), latitude: doublePrecision('latitude').notNull(), longitude: doublePrecision('longitude').notNull(), - location: geometry('location', { type: 'point', mode: 'xy', srid: 4326 }).notNull(), - tags: text('tags').array() - .default(sql`ARRAY[]::text[]`), userId: text('user_id').notNull(), - sensorWikiModel: text('sensor_wiki_model'), -}, (t) => ({ - spatialIndex: index('spatial_index').using('gist', t.location), -}),); + sensorWikiModel: text('sensor_wiki_model') +}); + +// Many-to-many relation between device - location +// https://orm.drizzle.team/docs/rqb#many-to-many +const deviceToLocation = pgTable( + 'device_to_location', + { + deviceId: text('device_id') + .notNull() + .references(() => device.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + locationId: integer('location_id') + .notNull() + .references(() => location.id), + time: timestamp('time').defaultNow() + .notNull(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.deviceId, t.locationId, t.time] }), + unique: unique().on(t.deviceId, t.locationId, t.time), // Device can only be at one location at the same time + }), +); const sensor = pgTable('sensor', { id: text('id') @@ -48,15 +74,17 @@ const sensor = pgTable('sensor', { title: text('title'), unit: text('unit'), sensorType: text('sensor_type'), - status: status('status').default('inactive'), + status: DeviceStatusEnum('status').default('inactive'), createdAt: timestamp('created_at').defaultNow() .notNull(), - updatedAt: timestamp('updated_at').defaultNow() + updatedAt: timestamp('updated_at') + .defaultNow() .notNull() .$onUpdateFn(() => new Date()), - deviceId: text('device_id').notNull() + deviceId: text('device_id') + .notNull() .references(() => device.id, { - onDelete: 'cascade', + onDelete: 'cascade' }), sensorWikiType: text('sensor_wiki_type'), sensorWikiPhenomenon: text('sensor_wiki_phenomenon'), @@ -188,10 +216,31 @@ const measurement = pgTable('measurement', { * Relations */ const deviceRelations = relations(device, ({ many, one }) => ({ + user: one(user, { + fields: [device.userId], + references: [user.id] + }), sensors: many(sensor), + locations: many(deviceToLocation), + logEntries: many(logEntry), accessToken: one(accessToken) })); +// Many-to-many +const deviceToLocationRelations = relations( + deviceToLocation, + ({ one }) => ({ + device: one(device, { + fields: [deviceToLocation.deviceId], + references: [device.id], + }), + geometry: one(location, { + fields: [deviceToLocation.locationId], + references: [location.id], + }), + }), +); + const sensorRelations = relations(sensor, ({ one }) => ({ device: one(device, { fields: [sensor.deviceId], @@ -242,6 +291,52 @@ const accessTokenRelations = relations(accessToken, ({ one }) => ({ }) })); +const location = pgTable( + 'location', + { + id: serial('id').primaryKey(), + location: geometry('location', { + type: 'point', + mode: 'xy', + srid: 4326 + }).notNull() + }, + (t) => ({ + locationIndex: index('location_index').using('gist', t.location), + unique_location: unique().on(t.location) + }) +); + +/** + * Relations + * 1. One-to-many: Location - Measurement (One location can have many measurements) + */ +const locationRelations = relations(location, ({ many }) => ({ + measurements: many(measurement) +})); + +// Table definition +const logEntry = pgTable('log_entry', { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + content: text('content').notNull(), + createdAt: timestamp('created_at').defaultNow() + .notNull(), + public: boolean('public').default(false) + .notNull(), + deviceId: text('device_id').notNull(), +}); + +// Relations definition +const logEntryRelations = relations(logEntry, ({ one }) => ({ + device: one(device, { + fields: [logEntry.deviceId], + references: [device.id], + }), +})); + module.exports.accessTokenTable = accessToken; module.exports.deviceTable = device; module.exports.sensorTable = sensor; @@ -259,3 +354,9 @@ module.exports.sensorRelations = sensorRelations; module.exports.userRelations = userRelations; module.exports.profileRelations = profileRelations; module.exports.refreshTokenRelations = refreshTokenRelations; +module.exports.locationTable = location; +module.exports.locationRelations = locationRelations; +module.exports.deviceToLocationTable = deviceToLocation; +module.exports.deviceToLocationRelations = deviceToLocationRelations; +module.exports.logEntryTable = logEntry; +module.exports.logEntryRelations = logEntryRelations; diff --git a/packages/models/src/drizzle.js b/packages/models/src/drizzle.js index d37f0e36..750cdbd3 100644 --- a/packages/models/src/drizzle.js +++ b/packages/models/src/drizzle.js @@ -20,7 +20,14 @@ const { accessTokenTable, refreshTokenRelations, refreshTokenTable, - measurementTable + measurementTable, + locationTable, + locationRelations, + deviceToLocationTable, + deviceToLocationRelations, + logEntryTable, + logEntryRelations, + tokenBlacklistTable } = require('../schema/schema'); const getDBUri = function getDBUri (uri) { @@ -42,7 +49,8 @@ const getDBUri = function getDBUri (uri) { }; const pool = new Pool({ - connectionString: getDBUri() + connectionString: getDBUri(), + ssl: false }); const schema = { @@ -61,7 +69,14 @@ const schema = { userRelations, profileRelations, accessTokenRelations, - refreshTokenRelations + refreshTokenRelations, + locationTable, + locationRelations, + deviceToLocationTable, + deviceToLocationRelations, + logEntryTable, + logEntryRelations, + tokenBlacklistTable }; const db = drizzle(pool, { From e515d5fbe58aa8d8bb57619d32867592374a3d8e Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Thu, 19 Dec 2024 12:50:41 +0100 Subject: [PATCH 20/34] check db connection before startup --- packages/api/app.js | 7 ++++--- packages/models/index.js | 9 ++++++--- packages/models/src/drizzle.js | 23 +++++++++++++++++++++-- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/api/app.js b/packages/api/app.js index 8e517592..9cb900cf 100644 --- a/packages/api/app.js +++ b/packages/api/app.js @@ -10,7 +10,7 @@ 'use strict'; -const { db } = require('@sensebox/opensensemap-api-models'), +const { isReady } = require('@sensebox/opensensemap-api-models'), restify = require('restify'), { fullResponse, @@ -55,9 +55,10 @@ if (config.get('logLevel') === 'debug') { const run = async function () { try { - // TODO: Get a client from the Pool and test connection - await db.connect(); + // Check if the database is ready + await isReady(); + // Load routes routes(server); // start the server diff --git a/packages/models/index.js b/packages/models/index.js index f89d8c28..1a3088a2 100644 --- a/packages/models/index.js +++ b/packages/models/index.js @@ -13,7 +13,8 @@ config.util.setModuleDefaults('openSenseMap-API-models', { user: 'postgres', userpass: 'postgres', db: 'opensensemap', - database_url: '' + database_url: '', + ssl: false }, integrations: { ca_cert: '', @@ -56,7 +57,8 @@ const { model: Box } = require('./src/box/box'), { model: Claim } = require('./src/box/claim'), utils = require('./src/utils'), decoding = require('./src/measurement/decoding'), - db = require('./src/db'); + db = require('./src/db'), + isReady = require('./src/drizzle').isReady; module.exports = { Box, @@ -66,5 +68,6 @@ module.exports = { User, utils, decoding, - db + db, + isReady }; diff --git a/packages/models/src/drizzle.js b/packages/models/src/drizzle.js index 750cdbd3..c15f4f4a 100644 --- a/packages/models/src/drizzle.js +++ b/packages/models/src/drizzle.js @@ -29,6 +29,7 @@ const { logEntryRelations, tokenBlacklistTable } = require('../schema/schema'); +const { sql } = require('drizzle-orm'); const getDBUri = function getDBUri (uri) { // if available, use user specified db connection uri @@ -48,11 +49,24 @@ const getDBUri = function getDBUri (uri) { return `postgresql://${user}:${userpass}@${host}:${port}/${db}`; }; +const isReady = async function isReady () { + try { + await db.execute(sql`select 1`); + } catch (error) { + throw new Error(error); + } +}; + const pool = new Pool({ connectionString: getDBUri(), - ssl: false + ssl: config.get('ssl') === 'true' ? true : false }); +// TODO: attach event listener +// pool.on('connect', () => { +// console.log('connected to the db'); +// }); + const schema = { accessTokenTable, refreshTokenTable, @@ -83,4 +97,9 @@ const db = drizzle(pool, { schema }); -module.exports.db = db; +module.exports = { + db, + isReady +}; +// module.exports.db = db; +// module.exports.isReady = isReady; From 2d716c67c89b8293626d246a4550975c1fc72585 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Fri, 20 Dec 2024 10:57:21 +0100 Subject: [PATCH 21/34] wrap device creation in transaction --- packages/api/lib/helpers/jwtHelpers.js | 6 +- packages/api/lib/helpers/tokenBlacklist.js | 29 ++---- packages/models/src/device/index.js | 106 ++++++++++++++------- packages/models/src/token/index.js | 11 ++- packages/models/src/token/refresh.js | 2 +- 5 files changed, 91 insertions(+), 63 deletions(-) diff --git a/packages/api/lib/helpers/jwtHelpers.js b/packages/api/lib/helpers/jwtHelpers.js index 30e79c30..e6d1a07c 100644 --- a/packages/api/lib/helpers/jwtHelpers.js +++ b/packages/api/lib/helpers/jwtHelpers.js @@ -5,7 +5,7 @@ const { findUserByEmailAndRole } = require('@sensebox/opensensemap-api-models/sr const config = require('config'), jwt = require('jsonwebtoken'), hashJWT = require('./jwtRefreshTokenHasher'), - { addTokenToBlacklist, addTokenHashToBlacklist, isTokenBlacklisted } = require('./tokenBlacklist'), + { addTokenToBlacklist, addTokenHashToBlacklist, isTokenBlacklisted, addRefreshTokenToBlacklist } = require('./tokenBlacklist'), { v4: uuidv4 } = require('uuid'), moment = require('moment'), { User } = require('@sensebox/opensensemap-api-models'), @@ -68,8 +68,8 @@ const refreshJwt = async function refreshJwt (refreshToken) { throw new ForbiddenError('Refresh token invalid or too old. Please sign in with your username and password.'); } - // TODO: invalidate old token - // addTokenHashToBlacklist(refreshToken); + // Add the old refresh token to the blacklist + addRefreshTokenToBlacklist(refreshToken); const { token, refreshToken: newRefreshToken } = await createToken(user); diff --git a/packages/api/lib/helpers/tokenBlacklist.js b/packages/api/lib/helpers/tokenBlacklist.js index 552c3ef5..87cebae0 100644 --- a/packages/api/lib/helpers/tokenBlacklist.js +++ b/packages/api/lib/helpers/tokenBlacklist.js @@ -1,17 +1,8 @@ 'use strict'; -const { insertTokenToBlacklist, findToken } = require('@sensebox/opensensemap-api-models/src/token'); +const { insertTokenToBlacklist, findToken, insertTokenToBlacklistWithExpiresAt } = require('@sensebox/opensensemap-api-models/src/token'); const hashJWT = require('./jwtRefreshTokenHasher'); - -// TODO: Move this to pg_cron -// const cleanupExpiredTokens = function cleanupExpiredTokens () { -// const now = Date.now() / 1000; -// for (const jti of Object.keys(tokenBlacklist)) { -// if (tokenBlacklist[jti].exp < now) { -// delete tokenBlacklist[jti]; -// } -// } -// }; +const moment = require('moment'); const isTokenBlacklisted = async function isTokenBlacklisted (token, tokenString) { if (!token.jti) { // token has no id.. -> shouldn't be accepted @@ -37,21 +28,13 @@ const addTokenToBlacklist = function addTokenToBlacklist (token, tokenString) { } }; -const addTokenHashToBlacklist = function addTokenHashToBlacklist (tokenHash) { - // cleanupExpiredTokens(); - - // if (typeof tokenHash === 'string') { - // // just set the exp claim to now plus one week to be sure - // tokenBlacklist[tokenHash] = { - // exp: moment.utc() - // .add(1, 'week') - // .unix() - // }; - // } +const addRefreshTokenToBlacklist = function addRefreshTokenToBlacklist (refreshToken) { + insertTokenToBlacklistWithExpiresAt('', refreshToken, moment.utc().add(1, 'week') + .unix()); }; module.exports = { isTokenBlacklisted, addTokenToBlacklist, - addTokenHashToBlacklist + addRefreshTokenToBlacklist }; diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index 7ada1a40..f4ad8612 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -1,11 +1,11 @@ 'use strict'; const crypto = require('crypto'); -const { deviceTable, sensorTable, accessTokenTable } = require('../../schema/schema'); +const { deviceTable, sensorTable, accessTokenTable, deviceToLocationTable, locationTable } = require('../../schema/schema'); const sensorLayouts = require('../box/sensorLayouts'); const { db } = require('../drizzle'); const ModelError = require('../modelError'); -const { inArray, arrayContains, sql, eq, asc, ilike, getTableColumns } = require('drizzle-orm'); +const { inArray, arrayContains, sql, eq, asc, ilike } = require('drizzle-orm'); const { insertMeasurement, insertMeasurements } = require('../measurement'); const SketchTemplater = require('@sensebox/sketch-templater'); @@ -94,42 +94,78 @@ const createDevice = async function createDevice (userId, params) { } } - // TODO: handle in transaction - const [device] = await db - .insert(deviceTable) - .values({ - userId, - name, - exposure, - description, - latitude: location[1], - longitude: location[0], - location: sql`ST_SetSRID(ST_MakePoint(${location[1]}, ${location[0]}), 4326)`, - useAuth, - model, - tags: grouptag - }) - .returning(); - - const [accessToken] = await db.insert(accessTokenTable).values({ - deviceId: device.id, - token: crypto.randomBytes(32).toString('hex') - }) - .returning({ token: accessTokenTable.token }); - - // Iterate over sensors and add device id - sensors = sensors.map((sensor) => ({ - deviceId: device.id, - ...sensor - })); - - const deviceSensors = await db.insert(sensorTable).values(sensors) - .returning(); + // Handle everything in a transaction to ensure consistency + const device = await db.transaction(async (tx) => { + const [device] = await tx + .insert(deviceTable) + .values({ + userId, + name, + exposure, + description, + useAuth, + model, + latitude: location[1], + longitude: location[0], + tags: grouptag + }) + .returning(); + + const [geometry] = await tx + .insert(locationTable) + .values({ + location: sql`ST_SetSRID(ST_MakePoint(${location[1]}, ${location[0]}), 4326)` + }) + .onConflictDoNothing() + .returning({ id: locationTable.id }); + + if (geometry) { + // Create location relation + await tx + .insert(deviceToLocationTable) + .values({ deviceId: device.id, locationId: geometry.id }); + } else { + // Get location id + const geom = await tx.query.locationTable.findFirst({ + columns: { + id: true + }, + where: sql`ST_Equals(${locationTable.location}, ST_SetSRID(ST_MakePoint(${location[1]}, ${location[0]}), 4326))` + }); + + // Create location relation + await tx + .insert(deviceToLocationTable) + .values({ deviceId: device.id, locationId: geom.id }); + } - device['accessToken'] = accessToken.token; - device['sensors'] = deviceSensors; + const [accessToken] = await tx + .insert(accessTokenTable) + .values({ + deviceId: device.id, + token: crypto.randomBytes(32).toString('hex') + }) + .returning({ token: accessTokenTable.token }); + + // Iterate over sensors and add device id + sensors = sensors.map((sensor) => ({ + deviceId: device.id, + ...sensor + })); + + const deviceSensors = await tx + .insert(sensorTable) + .values(sensors) + .returning(); + + device['accessToken'] = accessToken.token; + device['sensors'] = deviceSensors; + + return device; + }); return device; + }; const deleteDevice = async function (filter) { diff --git a/packages/models/src/token/index.js b/packages/models/src/token/index.js index 14f7e963..6117dd07 100644 --- a/packages/models/src/token/index.js +++ b/packages/models/src/token/index.js @@ -13,6 +13,14 @@ const insertTokenToBlacklist = async function (hash, token) { }); }; +const insertTokenToBlacklistWithExpiresAt = async function (hash, token, expiresAt) { + await db.insert(tokenBlacklistTable).values({ + hash, + token, + expiresAt: moment.unix(expiresAt) + }); +}; + const findToken = async function (hash) { const blacklistedToken = await db.select().from(tokenBlacklistTable) .where(eq(tokenBlacklistTable.hash, hash)); @@ -22,5 +30,6 @@ const findToken = async function (hash) { module.exports = { findToken, - insertTokenToBlacklist + insertTokenToBlacklist, + insertTokenToBlacklistWithExpiresAt }; diff --git a/packages/models/src/token/refresh.js b/packages/models/src/token/refresh.js index d69649ae..2b52eacd 100644 --- a/packages/models/src/token/refresh.js +++ b/packages/models/src/token/refresh.js @@ -24,7 +24,7 @@ const findRefreshTokenUser = async function findRefreshTokenUser (token) { } }); - return token1.user; + return token1 ? token1.user : token1; }; module.exports = { From e2347639ee8f0316453c62e8d23c5fd453ce264e Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Fri, 20 Dec 2024 11:42:19 +0100 Subject: [PATCH 22/34] rewrite delete user --- .../api/lib/controllers/usersController.js | 12 +- .../migrations/0015_cascade_user_device.sql | 5 + .../models/migrations/meta/0015_snapshot.json | 925 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 7 + packages/models/schema/schema.js | 6 +- packages/models/src/user/index.js | 13 +- 6 files changed, 958 insertions(+), 10 deletions(-) create mode 100644 packages/models/migrations/0015_cascade_user_device.sql create mode 100644 packages/models/migrations/meta/0015_snapshot.json diff --git a/packages/api/lib/controllers/usersController.js b/packages/api/lib/controllers/usersController.js index 1cf10e9d..ffa62a9f 100644 --- a/packages/api/lib/controllers/usersController.js +++ b/packages/api/lib/controllers/usersController.js @@ -19,7 +19,7 @@ const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/bo const { findDevices, findDevicesByUserId } = require('@sensebox/opensensemap-api-models/src/device'); const { initPasswordReset, resetOldPassword } = require('@sensebox/opensensemap-api-models/src/password'); const { checkPassword } = require('@sensebox/opensensemap-api-models/src/password/utils'); -const { createUser, findUserByNameOrEmail, resendEmailConfirmation, confirmEmail } = require('@sensebox/opensensemap-api-models/src/user'); +const { createUser, findUserByNameOrEmail, resendEmailConfirmation, confirmEmail, destroyUser } = require('@sensebox/opensensemap-api-models/src/user'); /** * define for nested user parameter for box creation request @@ -358,15 +358,15 @@ const deleteUser = async function deleteUser (req, res) { const { password } = req._userParams; try { - await req.user.checkPassword(password); - invalidateToken(req); + await checkPassword(password, req.user.password); + await invalidateToken(req); - await req.user.destroyUser(); + const deletedUser = await destroyUser(req.user); res.send(200, { code: 'Ok', - message: 'User and all boxes of user marked for deletion. Bye Bye!', + message: `User and all boxes of user marked for deletion. Bye Bye ${deletedUser[0].name}!`, }); - clearCache(['getBoxes', 'getStats']); + // clearCache(['getBoxes', 'getStats']); postToMattermost( `User deleted: ${req.user.name} (${redactEmail(req.user.email)})` ); diff --git a/packages/models/migrations/0015_cascade_user_device.sql b/packages/models/migrations/0015_cascade_user_device.sql new file mode 100644 index 00000000..18bef63a --- /dev/null +++ b/packages/models/migrations/0015_cascade_user_device.sql @@ -0,0 +1,5 @@ +DO $$ BEGIN + ALTER TABLE "device" ADD CONSTRAINT "device_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/models/migrations/meta/0015_snapshot.json b/packages/models/migrations/meta/0015_snapshot.json new file mode 100644 index 00000000..f92cce35 --- /dev/null +++ b/packages/models/migrations/meta/0015_snapshot.json @@ -0,0 +1,925 @@ +{ + "id": "7ab04c61-4f5c-4c1b-b477-e79a02b9ce80", + "prevId": "6ba30027-0bcb-421c-8e58-5777d52f3422", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "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": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "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 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + }, + "public.token_blacklist": { + "name": "token_blacklist", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "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" + ] + } + } + }, + "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": "integer", + "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" + ] + } + } + }, + "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": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "home_v2_lora", + "home_v2_ethernet", + "home_v2_ethernet_feinstaub", + "home_v2_wifi", + "home_v2_wifi_feinstaub", + "home_ethernet", + "home_wifi", + "home_ethernet_feinstaub", + "home_wifi_feinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "luftdaten_pms1003", + "luftdaten_pms1003_bme280", + "luftdaten_pms3003", + "luftdaten_pms3003_bme280", + "luftdaten_pms5003", + "luftdaten_pms5003_bme280", + "luftdaten_pms7003", + "luftdaten_pms7003_bme280", + "luftdaten_sps30_bme280", + "luftdaten_sps30_sht3x", + "hackair_home_v2", + "custom" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json index 7e4902b9..0496ae3e 100644 --- a/packages/models/migrations/meta/_journal.json +++ b/packages/models/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1734606789875, "tag": "0014_update_tables_and_relations", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1734691173637, + "tag": "0015_cascade_user_device", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js index d79bd5f6..08e9b001 100644 --- a/packages/models/schema/schema.js +++ b/packages/models/schema/schema.js @@ -39,7 +39,11 @@ const device = pgTable('device', { expiresAt: date('expires_at', { mode: 'date' }), latitude: doublePrecision('latitude').notNull(), longitude: doublePrecision('longitude').notNull(), - userId: text('user_id').notNull(), + userId: text('user_id').notNull() + .references(() => user.id, { + onDelete: 'cascade', + onUpdate: 'no action' + }), sensorWikiModel: text('sensor_wiki_model') }); diff --git a/packages/models/src/user/index.js b/packages/models/src/user/index.js index d97f0ba5..9f16e506 100644 --- a/packages/models/src/user/index.js +++ b/packages/models/src/user/index.js @@ -26,6 +26,9 @@ const findUserByEmailAndRole = async function findUserByEmailAndRole ({ role }) { const user = await db.query.userTable.findFirst({ + with: { + password: true + }, where: (user, { eq, and }) => and(eq(user.email, email.toLowerCase(), eq(user.role, role))) }); @@ -55,8 +58,12 @@ const createUser = async function createUser (name, email, password, language) { } }; -// TODO: delete User -const deleteUser = async function deleteUser () {}; +const destroyUser = async function destroyUser (user) { + return await db + .delete(userTable) + .where(eq(userTable.id, user.id)) + .returning({ name: userTable.name }); +}; const updateUser = async function updateUser ( userId, @@ -207,7 +214,7 @@ module.exports = { findUserByNameOrEmail, findUserByEmailAndRole, createUser, - deleteUser, + destroyUser, updateUser, confirmEmail, resendEmailConfirmation From 6c86fd7b81ee528ccb3bba996b7f4aeca87a117c Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Fri, 20 Dec 2024 11:50:21 +0100 Subject: [PATCH 23/34] clean up unused imports --- packages/api/lib/controllers/usersController.js | 8 +++----- packages/api/lib/helpers/jwtHelpers.js | 5 ++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/api/lib/controllers/usersController.js b/packages/api/lib/controllers/usersController.js index ffa62a9f..f6c64f83 100644 --- a/packages/api/lib/controllers/usersController.js +++ b/packages/api/lib/controllers/usersController.js @@ -1,11 +1,10 @@ 'use strict'; -const { User } = require('@sensebox/opensensemap-api-models'), - { InternalServerError, ForbiddenError } = require('restify-errors'), + +const { InternalServerError, ForbiddenError } = require('restify-errors'), { checkContentType, redactEmail, - clearCache, postToMattermost, } = require('../helpers/apiUtils'), { retrieveParameters } = require('../helpers/userParamHelpers'), @@ -16,7 +15,7 @@ const { User } = require('@sensebox/opensensemap-api-models'), invalidateToken, } = require('../helpers/jwtHelpers'); const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/box'); -const { findDevices, findDevicesByUserId } = require('@sensebox/opensensemap-api-models/src/device'); +const { findDevicesByUserId } = require('@sensebox/opensensemap-api-models/src/device'); const { initPasswordReset, resetOldPassword } = require('@sensebox/opensensemap-api-models/src/password'); const { checkPassword } = require('@sensebox/opensensemap-api-models/src/password/utils'); const { createUser, findUserByNameOrEmail, resendEmailConfirmation, confirmEmail, destroyUser } = require('@sensebox/opensensemap-api-models/src/user'); @@ -366,7 +365,6 @@ const deleteUser = async function deleteUser (req, res) { code: 'Ok', message: `User and all boxes of user marked for deletion. Bye Bye ${deletedUser[0].name}!`, }); - // clearCache(['getBoxes', 'getStats']); postToMattermost( `User deleted: ${req.user.name} (${redactEmail(req.user.email)})` ); diff --git a/packages/api/lib/helpers/jwtHelpers.js b/packages/api/lib/helpers/jwtHelpers.js index e6d1a07c..eca08d2a 100644 --- a/packages/api/lib/helpers/jwtHelpers.js +++ b/packages/api/lib/helpers/jwtHelpers.js @@ -1,14 +1,13 @@ 'use strict'; -const { addRefreshToken, deleteRefreshToken, findRefreshToken, findRefreshTokenUser } = require('@sensebox/opensensemap-api-models/src/token/refresh'); +const { addRefreshToken, deleteRefreshToken, findRefreshTokenUser } = require('@sensebox/opensensemap-api-models/src/token/refresh'); const { findUserByEmailAndRole } = require('@sensebox/opensensemap-api-models/src/user'); const config = require('config'), jwt = require('jsonwebtoken'), hashJWT = require('./jwtRefreshTokenHasher'), - { addTokenToBlacklist, addTokenHashToBlacklist, isTokenBlacklisted, addRefreshTokenToBlacklist } = require('./tokenBlacklist'), + { addTokenToBlacklist, isTokenBlacklisted, addRefreshTokenToBlacklist } = require('./tokenBlacklist'), { v4: uuidv4 } = require('uuid'), moment = require('moment'), - { User } = require('@sensebox/opensensemap-api-models'), { ForbiddenError } = require('restify-errors'); const { algorithm: jwt_algorithm, secret: jwt_secret, issuer: jwt_issuer, validity_ms: jwt_validity_ms } = config.get('jwt'); From 94ccf867f4aacb77ab1a23f21f258406c1ab6bb4 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Fri, 20 Dec 2024 13:42:10 +0100 Subject: [PATCH 24/34] refactor refresh-auth route --- .../api/lib/controllers/usersController.js | 7 ++++++- packages/api/lib/helpers/jwtHelpers.js | 18 ++++++++++++++++-- packages/api/lib/routes.js | 4 ++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/api/lib/controllers/usersController.js b/packages/api/lib/controllers/usersController.js index f6c64f83..f99da33f 100644 --- a/packages/api/lib/controllers/usersController.js +++ b/packages/api/lib/controllers/usersController.js @@ -5,7 +5,7 @@ const { InternalServerError, ForbiddenError } = require('restify-errors'), { checkContentType, redactEmail, - postToMattermost, + postToMattermost } = require('../helpers/apiUtils'), { retrieveParameters } = require('../helpers/userParamHelpers'), handleError = require('../helpers/errorHandler'), @@ -13,6 +13,7 @@ const { InternalServerError, ForbiddenError } = require('restify-errors'), createToken, refreshJwt, invalidateToken, + verifyJwtAndRefreshToken } = require('../helpers/jwtHelpers'); const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/box'); const { findDevicesByUserId } = require('@sensebox/opensensemap-api-models/src/device'); @@ -149,6 +150,10 @@ const signIn = async function signIn (req, res) { */ const refreshJWT = async function refreshJWT (req, res) { try { + // Check if refreshToken matches JWT Token + await verifyJwtAndRefreshToken(req._userParams.token, req._jwtString); + + // Now it´s time to refresh the JWT and invalidate the old one const { token, refreshToken, user } = await refreshJwt( req._userParams.token ); diff --git a/packages/api/lib/helpers/jwtHelpers.js b/packages/api/lib/helpers/jwtHelpers.js index eca08d2a..1a797f80 100644 --- a/packages/api/lib/helpers/jwtHelpers.js +++ b/packages/api/lib/helpers/jwtHelpers.js @@ -89,7 +89,10 @@ const verifyJwt = function verifyJwt (req, res, next) { return next(new ForbiddenError(jwtInvalidErrorMessage)); } - jwt.verify(jwtString, jwt_secret, jwtVerifyOptions, async function (err, decodedJwt) { + jwt.verify(jwtString, jwt_secret, { + ...jwtVerifyOptions, + ignoreExpiration: req.url === '/users/refresh-auth' ? true : false // ignore expiration for refresh endpoint + }, async function (err, decodedJwt) { if (err) { return next(new ForbiddenError(jwtInvalidErrorMessage)); } @@ -118,9 +121,20 @@ const verifyJwt = function verifyJwt (req, res, next) { }); }; +const verifyJwtAndRefreshToken = async function verifyJwtAndRefreshToken (refreshToken, jwtString) { + if (refreshToken !== hashJWT(jwtString)) { + return Promise.reject( + new ForbiddenError( + 'Refresh token invalid or too old. Please sign in with your username and password.' + ) + ); + } +}; + module.exports = { createToken, invalidateToken, refreshJwt, - verifyJwt + verifyJwt, + verifyJwtAndRefreshToken }; diff --git a/packages/api/lib/routes.js b/packages/api/lib/routes.js index c2e3a9a5..ffe7a011 100644 --- a/packages/api/lib/routes.js +++ b/packages/api/lib/routes.js @@ -93,10 +93,10 @@ const routes = { { path: `${usersPath}/request-password-reset`, method: 'post', handler: usersController.requestResetPassword, reference: 'api-Users-request-password-reset' }, { path: `${usersPath}/password-reset`, method: 'post', handler: usersController.resetPassword, reference: 'api-Users-password-reset' }, { path: `${usersPath}/confirm-email`, method: 'post', handler: usersController.confirmEmailAddress, reference: 'api-Users-confirm-email' }, - { path: `${usersPath}/sign-in`, method: 'post', handler: usersController.signIn, reference: 'api-Users-sign-in' }, - { path: `${usersPath}/refresh-auth`, method: 'post', handler: usersController.refreshJWT, reference: 'api-Users-refresh-auth' } + { path: `${usersPath}/sign-in`, method: 'post', handler: usersController.signIn, reference: 'api-Users-sign-in' } ], 'auth': [ + { path: `${usersPath}/refresh-auth`, method: 'post', handler: usersController.refreshJWT, reference: 'api-Users-refresh-auth' }, { path: `${usersPath}/me`, method: 'get', handler: usersController.getUser, reference: 'api-Users-getUser' }, { path: `${usersPath}/me`, method: 'put', handler: usersController.updateUser, reference: 'api-Users-updateUser' }, { path: `${usersPath}/me/boxes`, method: 'get', handler: usersController.getUserBoxes, reference: 'api-Users-getUserBoxes' }, From 898d3257000da614db6a8b23f8d0db7d062b22ba Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Thu, 9 Jan 2025 17:44:11 +0100 Subject: [PATCH 25/34] insert refresh token to blacklist table --- packages/api/lib/helpers/tokenBlacklist.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/lib/helpers/tokenBlacklist.js b/packages/api/lib/helpers/tokenBlacklist.js index 87cebae0..4931bb6e 100644 --- a/packages/api/lib/helpers/tokenBlacklist.js +++ b/packages/api/lib/helpers/tokenBlacklist.js @@ -29,7 +29,7 @@ const addTokenToBlacklist = function addTokenToBlacklist (token, tokenString) { }; const addRefreshTokenToBlacklist = function addRefreshTokenToBlacklist (refreshToken) { - insertTokenToBlacklistWithExpiresAt('', refreshToken, moment.utc().add(1, 'week') + insertTokenToBlacklistWithExpiresAt(refreshToken, '', moment.utc().add(1, 'week') .unix()); }; From 9fab2700508bc1cb0e2eefbf340b9471c2ea85b8 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Fri, 10 Jan 2025 16:18:49 +0100 Subject: [PATCH 26/34] fix docker test setup --- .scripts/run-tests.sh | 21 ++++++++--- package.json | 8 +++-- tests/docker-compose.yml | 78 ++++++++++++++++++++-------------------- 3 files changed, 61 insertions(+), 46 deletions(-) diff --git a/.scripts/run-tests.sh b/.scripts/run-tests.sh index 1e92befe..3d8fa9a2 100755 --- a/.scripts/run-tests.sh +++ b/.scripts/run-tests.sh @@ -44,19 +44,32 @@ trap cleanup EXIT function executeTests() { runComposeCommand down -v --remove-orphans + # Start database + runComposeCommand up --quiet-pull -d --remove-orphans db + + # Allow the dust and database to settle + sleep 3 + + # Run database migrations from the models package + runComposeCommand run -T --rm osem-api yarn models:db:migrate + + # Allow the dust to settle + sleep 3 + + # Run API tests if [[ -z $only_models_tests ]]; then - runComposeCommand up --quiet-pull -d --force-recreate --remove-orphans + runComposeCommand up --quiet-pull -d --force-recreate --remove-orphans osem-api mailhog mosquitto redis-stack - # Allow the dust to settle + #Allow the dust to settle sleep 3 runComposeCommand exec -T osem-api yarn mocha --exit tests/waitForHttp.js tests/tests.js runComposeCommand stop osem-api fi - runComposeCommand up --quiet-pull -d --remove-orphans db mailer + # Run Models tests # use ./node_modules/.bin/mocha because the workspace does not have the devDependency mocha - runComposeCommand run -T --workdir=/usr/src/app/packages/models osem-api ../../node_modules/.bin/mocha --exit test/waitForDatabase test/index + runComposeCommand run -T --workdir=/usr/src/app/packages/models osem-api ../../node_modules/.bin/mocha --exit test/index } case "$cmd" in diff --git a/package.json b/package.json index bc7ad789..eff3e800 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,14 @@ "packages/*" ], "scripts": { + "dev": "node packages/api/app.js", + "db:up": "docker compose up -d db", + "db:down": "docker compose down db", + "models:db:migrate": "yarn workspace @sensebox/opensensemap-api-models db:migrate", "start": "node packages/api/app.js", - "start-dev-db": "sudo docker-compose up -d db", - "stop-dev-db": "sudo docker-compose down db", "build-test-env": "./.scripts/run-tests.sh build", "test": "./.scripts/run-tests.sh", - "test-models": "./.scripts/run-tests.sh only_models", + "test:models": "./.scripts/run-tests.sh only_models", "NOTpretest": "node tests/waitForHttp", "tag-container": "./.scripts/npm_tag-container.sh", "lint:ci": "eslint --ignore-pattern node_modules \"{tests,packages}/**/*.js\"", diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 952053fe..ecf64c95 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -54,24 +54,24 @@ services: - db - redis-stack - mailer: - image: ghcr.io/opensensemap/mailer:main - platform: linux/amd64 - environment: - - SMTP_HOST=mailhog - - SMTP_PORT=1025 - - SMTP_SECURE=false - - SMTP_USERNAME=ignored - - SMTP_PASSWORD=ignored - - REDIS_HOST=redis-stack - - REDIS_PORT=6379 - - REDIS_USERNAME=queue - - REDIS_PASSWORD=somepassword - - REDIS_DB=0 - - BULLMQ_QUEUE_NAME=mails - depends_on: - - mailhog - - redis-stack + # mailer: + # image: ghcr.io/opensensemap/mailer:main + # platform: linux/amd64 + # environment: + # - SMTP_HOST=mailhog + # - SMTP_PORT=1025 + # - SMTP_SECURE=false + # - SMTP_USERNAME=ignored + # - SMTP_PASSWORD=ignored + # - REDIS_HOST=redis-stack + # - REDIS_PORT=6379 + # - REDIS_USERNAME=queue + # - REDIS_PASSWORD=somepassword + # - REDIS_DB=0 + # - BULLMQ_QUEUE_NAME=mails + # depends_on: + # - mailhog + # - redis-stack mailhog: image: mailhog/mailhog:v1.0.1 @@ -79,27 +79,27 @@ services: ports: - "8025:8025" - mqtt-osem-integration: - image: ghcr.io/sensebox/mqtt-osem-integration:v0.1.0 - platform: linux/amd64 - depends_on: - - db - environment: - NODE_CONFIG: |- - { - "grpc": { - "certificates": { - "ca_cert": "-----BEGIN CERTIFICATE-----\nMIIE8jCCAtqgAwIBAgIBATANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDEw5vcGVu\nU2Vuc2VNYXBDQTAeFw0xODAxMjQxMzQ1NDlaFw0yODAxMjQxMzQ1NDlaMBkxFzAV\nBgNVBAMTDm9wZW5TZW5zZU1hcENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC\nCgKCAgEAtRv1MmOOMnL1vqaFF1uf6XzHvUUkFoQsktRMQgr4Kq4TpBJdt2yrEWAf\ngBup20//Hb3pu1tGHnINRlrfnRbu/0Twq0iXP+zjzrn1TIppbQb8Hr5gci25vY8e\nfc3ZRhYDrPyf+Z+F3U5Skr2itvBEshSiy3L53YG+JJ6ohPeVW3ePoBOIZvFYEGNV\nDsEbt3DVAjdFOfB4SypZG9UyX8xNUw7aAbjLib9CkdT2hEDiZ6iiKDklxZnNt1fm\nYQ/FGJRMd3ZTMddrGcexPSY1dQOJHrnlf+fVkahfqLD4RmVaL0O4cc/e/YIJQEZi\nblD+cUduKl0itkeXj1PEbYyngGixkJ+9+WuZvOwTqNrCgWU1uXrRH3UKn2XGYQau\nauq4AqnEgvMSev7nxdXEznzey1ugEeNSHXyyvj70KrUEHJ1kL4aL53YD0Xd8Y0hL\n88MHl3GkjkojZ68U0a/0TUSfTuv+JdP19HfZY5qVKst1309tlgaOiQXafAsiVBRr\ncieFHXxvoruQy/6pTwGRtfWbyujib+xXaTX36y08IXK0Jb45WJDoVgPswsHYqF6E\n/AH0NSRZrXckfMQviUea27/lOD1M96cwslDjvOngJdUu1zBGLdfQ8SWcs1HqdG7i\nq+Iuh/UGq4aJgRTENEBrc4kjQQyZszXBJmRxAWKIgN0h+jEzGIsCAwEAAaNFMEMw\nDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFJl6\n7zgZ/9GRVgb1dBglkSyXfGNRMA0GCSqGSIb3DQEBCwUAA4ICAQBYJIxwwt+/C1+6\nyjIDzQ6wkfdDWzBj9kfV90plp4zX7rhT+M2t/en0/lN2BgK3J1fw+PWbku6A19hA\nniZ/mBZrKki0UzzqGO1/ovh3izC2zeRL1lqwxdxj29+2waPk2vH/sy97AuY3q/5G\nE+H96RReOJZxysIAg2UxXpvgdupnTGMrh+fuU7iGm9NYLSLuD2SaZY/ZThSPFXOh\nb59W6gMmNKo6rN09jj994rJQfsxH2JOkqiTHfwXp3Ch4Zg80XC9RvvRk6S4dtnLe\npQy+Bg3YjQ+sSUIaNrvWoP8ut+9aHdfSlQi/7aYD7tYEhdTK7G++4RWZ0C31G9an\n6Yjcg1lJDWkP+ii91danhisdCwP2xteRXzpFM/uJ/dY+xBaX/Kp12bhF8PzbgJHa\nrsDApLlywVSrZtglSJ5eBhDbuLPEGqATWCExvlPO2R4MF1CID/+aybj5o9Zfmok4\nU8Z1QwLZElpTh1OhkYCyIzRJ2eG1Hx6svLh6nx4ai3iumbjzWh+E4xrncdTBA6hk\nRKF+yZKDfpFF8iwemKExthQwCSjqEGi6bYf02Gw6A226FSqdD2Tw0TghnXtT9fQ6\nyBx1uNSDDXdCFWmPzvZIMLe335mP2RKQrcGIbAUi0WgTuqFNyCshnoWmQigP6lqK\nC5m4Hth2wvbwzeqDD2kvRQfpipy9Cw==\n-----END CERTIFICATE-----\n", - "server_cert": "-----BEGIN CERTIFICATE-----\nMIIFajCCA1KgAwIBAgIQBVxnXzVjuOz8ThLtyglyfDANBgkqhkiG9w0BAQsFADAZ\nMRcwFQYDVQQDEw5vcGVuU2Vuc2VNYXBDQTAeFw0xODAxMjQxMzQ2MTFaFw0yODAx\nMjQxMzQ1NDhaMCcxJTAjBgNVBAMMHG1xdHQtb3NlbS1pbnRlZ3JhdGlvbl9zZXJ2\nZXIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC1G/UyY44ycvW+poUX\nW5/pfMe9RSQWhCyS1ExCCvgqrhOkEl23bKsRYB+AG6nbT/8dvem7W0Yecg1GWt+d\nFu7/RPCrSJc/7OPOufVMimltBvwevmByLbm9jx59zdlGFgOs/J/5n4XdTlKSvaK2\n8ESyFKLLcvndgb4knqiE95Vbd4+gE4hm8VgQY1UOwRu3cNUCN0U58HhLKlkb1TJf\nzE1TDtoBuMuJv0KR1PaEQOJnqKIoOSXFmc23V+ZhD8UYlEx3dlMx12sZx7E9JjV1\nA4keueV/59WRqF+osPhGZVovQ7hxz979gglARmJuUP5xR24qXSK2R5ePU8RtjKeA\naLGQn735a5m87BOo2sKBZTW5etEfdQqfZcZhBq5q6rgCqcSC8xJ6/ufF1cTOfN7L\nW6AR41IdfLK+PvQqtQQcnWQvhovndgPRd3xjSEvzwweXcaSOSiNnrxTRr/RNRJ9O\n6/4l0/X0d9ljmpUqy3XfT22WBo6JBdp8CyJUFGtyJ4UdfG+iu5DL/qlPAZG19ZvK\n6OJv7FdpNffrLTwhcrQlvjlYkOhWA+zCwdioXoT8AfQ1JFmtdyR8xC+JR5rbv+U4\nPUz3pzCyUOO86eAl1S7XMEYt19DxJZyzUep0buKr4i6H9QarhomBFMQ0QGtziSNB\nDJmzNcEmZHEBYoiA3SH6MTMYiwIDAQABo4GfMIGcMA4GA1UdDwEB/wQEAwIDuDAd\nBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFJl67zgZ/9GR\nVgb1dBglkSyXfGNRMB8GA1UdIwQYMBaAFJl67zgZ/9GRVgb1dBglkSyXfGNRMCsG\nA1UdEQQkMCKCFW1xdHQtb3NlbS1pbnRlZ3JhdGlvboIJbG9jYWxob3N0MA0GCSqG\nSIb3DQEBCwUAA4ICAQA0AUViYK7DpfljVsmQG6S5S94pXHtUvG3qO8zHZu/NIkVA\nqZS+1DNN0ER9n0WNQuoFwUSgMEPFG++lrCMYFLFfGT1kHEILx8RD5Zk1n3LbInVc\n/fZOpfYqDQo6dw+J9nq1tKAVYajKq267EfmrOmoGnyR7mg+3kA21Nxm6vkzFEEBM\nQKjN35wbpjC5VV71wB7ERy/9WCwkzxOe+Da5xAN0fEW9aNSp1+VBMKUOugMlRAbF\n5SSO1lHFClsPGhUGrTk4Ng/gja77llPr3yMGP5TDyJKUvLU/6LAzLQGWyz8IPl4f\nnHRi0+wHnI7TN8jmBkoDd5Mf9TXIqAEAo3HuiQs0tGaVCPWksj8u8gzL5YcuOaN/\nypQVHqh69m8XRMHyffz3pqKK8s/3zg1jsZQcZlDB8BtBaRmagX5ZxT+hw4Flgi/w\nYNr5greSdRM9mSFzPqy+God23OGvZCcqS/KI1r8fsThf18xavD5ThI2Pb6NXxQ1l\nu1Lz38J0WPH9XUnMQsDw4D+Kycjt+FD67U6VOOQsStm578spNQoDA1dtfMZfUxAx\nFn22CSqwyEpkSpA2b1c0XYMyO0iUwuFPVM2EIpD9cYeqTuo8my4o6ad0nqMRV+oo\nys16TAfoKOlivdSKrHZOVRLdJS/GWn8GAtxhHdv+aNo5EoiFFKtYMsbpKyzQtQ==\n-----END CERTIFICATE-----\n", - "server_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIJKAIBAAKCAgEAtRv1MmOOMnL1vqaFF1uf6XzHvUUkFoQsktRMQgr4Kq4TpBJd\nt2yrEWAfgBup20//Hb3pu1tGHnINRlrfnRbu/0Twq0iXP+zjzrn1TIppbQb8Hr5g\nci25vY8efc3ZRhYDrPyf+Z+F3U5Skr2itvBEshSiy3L53YG+JJ6ohPeVW3ePoBOI\nZvFYEGNVDsEbt3DVAjdFOfB4SypZG9UyX8xNUw7aAbjLib9CkdT2hEDiZ6iiKDkl\nxZnNt1fmYQ/FGJRMd3ZTMddrGcexPSY1dQOJHrnlf+fVkahfqLD4RmVaL0O4cc/e\n/YIJQEZiblD+cUduKl0itkeXj1PEbYyngGixkJ+9+WuZvOwTqNrCgWU1uXrRH3UK\nn2XGYQauauq4AqnEgvMSev7nxdXEznzey1ugEeNSHXyyvj70KrUEHJ1kL4aL53YD\n0Xd8Y0hL88MHl3GkjkojZ68U0a/0TUSfTuv+JdP19HfZY5qVKst1309tlgaOiQXa\nfAsiVBRrcieFHXxvoruQy/6pTwGRtfWbyujib+xXaTX36y08IXK0Jb45WJDoVgPs\nwsHYqF6E/AH0NSRZrXckfMQviUea27/lOD1M96cwslDjvOngJdUu1zBGLdfQ8SWc\ns1HqdG7iq+Iuh/UGq4aJgRTENEBrc4kjQQyZszXBJmRxAWKIgN0h+jEzGIsCAwEA\nAQKCAgBsqjuyYh19k5BzNcKBQ05tb5sAqy19/QwphQvETISeRxgtx39HgQIbSMtd\nuDtwBU2S8NH+wkMOHWxtnDSzMoFv1FN60fE+P8pnzRerNxkOe7RmVd/UYi8h1296\nGDqXXLoT3ve1dMuC/2138iRhE0SEfPE4lOHqz9/gZPnD3jFVUiVw7IdZDNHD83Wj\nhqY0qJSF4de9bdUfdGdG1eKFrDVw8mZHxjMJkSJGEbtfmva9L2csLy3EpAXUTf9C\nmY2us7w1qV89dn0iWLi1cel9LgPl1bAn0FhKLvZGZvhwdHtqBH30e77V6GHYmOKS\nQjKIkU0+Sed76vS64I3pFQ2jdC2lEBmvYrdX4n8Ab/yWBi/KyouJW583KlevLbPk\n0GsDVQiolLLl4z5gcpGCoLH8jm/soGfXWyTBbd5Ei+01Zf/yYHTtYPh2WYLFBZ4z\nnnhbTcquOoqF44wXPRqOBKVX1amiMG4llmIne89DGlZQaP9A+4Q+o/5aTkInupwT\nBI2JRLe/YZb/0S/wOGKGFygvM1p1I/ToCg2FOX0ZWzkRZ9Sr9RAKMX3VHImYRSID\nszU/PSPxSAjy22u1vgO6wAAaOaO1OS4JwpKbhqVyjuvzvIwHMVfTgqyhIRsk39Ev\nuTqHTse8v6S+fJY3RDLR1ereWJgPpzKnDPxg2F5ROXVGaDZb0QKCAQEAzJ5c3mDz\nKsrsf0HpmK+w95gb4pPd529BLNVv/tKBNqUkg1jsYriL7nGLVNSKFDmIyokOb/sI\nqzDQRNg/4LanH8lhrmtmCeH05Tkz3Aj1IwF8I8/fr/57Aj0DgxFSJ3aDgys2q4ld\ngIR3fe4hIDF0rXb8bAYqOt7rfl3qvbRSsLjQCi358fHRQeMGDjyC9FZ6POwdQFAP\n531uXNYnnePnuXfuSgzVBp83pW3V8rW4YkpFoPhT38msVny63EAhRcoot/2X96Cd\njRQNq/5v8G0luBv2DEDSP/CDfTZKhkMaauf8KIACLeYYNTz2gFvDfa5TroqAe5De\nHZHfqh94wyBV9wKCAQEA4pZTGWk1S0WQz01AlMvRXiM7H7jEAmddRgrpQ2w7g+Ss\nkJesYzREZ7j/kIpK3ACuQXjW/Tjlnpkp206ocCIoIwExOVG4vsewDEGaYqOvllro\n+GW7O4DhwgeUWAao/Ge1T/gu+8z/VM9N88udbc0xq8P0jn6xial9rLkhll5rZDS7\niIz1mFAzrADU5c7TvElXs86KX+T6+/sE2nNwsImaZHC1oclIHl9WnJy/qCPYFS04\nY6q9vg+vOlzRc6KgmOp/5Jex2oQsXKx45v0O8+mfLlyT6CBw4rWb0ksHC3aM8zCr\ncGmgbzqVYYIewnZ//yRm98Mty8p/m3p3iNNzHExdDQKCAQBfrel1HtZ1+x9tPi/x\n8q2IiTr4zvXjg3VxdniBKoO7Pqt9M7aNTwg3viZNy3ipjmG1ezMiD7t0+UVZ+9ia\nxi4NwggIHDZBhsQR75adXB7seIRI5qoNTKzOViNvRUkqJNPIIQvWWEw9jTOm0hPx\nTs7lUg8koBldH+H0XAwpGsnT0weMywTmKpIUAglR3N/LSyirlijzaryVHWTeylEK\nFojDhB4LyEZQa2EE3QA/FtQaOeqnI5dsvIv2gSqLVP15+dbiehV2eEdTsb3W4AoN\n3avWlFSQVDs8JMYHZbyhXX1b4hBaC8l5Fu/Y7SHC0aXu/fYpVqBPp2UFZLG2hjLc\n4yDvAoIBACzF84mz5loHVwP/ieFdHPPzFj3AbsrizeWHRmySOHhpeUfhEKlRrKqq\nPaW8DerHH6fETwcedREPxtuVAWeW+ENieu2OnmjkYH8rf2w6V/nn4N0kjQjHANUs\nVj3GoyGtBIDW08Hh0hpaFFc2RtdpkoUUZYC6vC4tla3Jrz9dTO8yFFR5NhZw0qUM\nTQVUBzbPb0sSZvln78hW47Ce2wenSSDLvLhJY7zMrfqoZp685nfYxam8FV43DzMD\nIEgvPHi67aan6vb44yM02XcbThcYdOHeXUOjFWtW44F8XdoABP4RAe9mj9MqylXI\nNnfKnqQ19zrCEIySaQC6BGC/F6Hh3QkCggEBAMQxjQVI0eUBv8C+/e9HsbvnvtPz\nBSpGdKKDvp+vfw8dP0rxCFHeDeBglTOGOB4uTUGGkU2l6HDA/pFdSOmsiXLpc/Ai\nQX4n2sj5R5W4mCiYVuHWo/BCkEF7utcwHu3raBhnYeuUooISfe2S8A/66PF9lnO9\n+FVODW6Q+qdHNH349AXwtgOS/neg16I5hw0PgMkcNuJl+bgXlyy+0O8N2/37ofb1\nbvj+34qtubGRdgtSP/EF8NxYWIz3VVqVyHtPOKoAR2Aa1ilg8ixbS1KfiqZejY6u\nTZMPvBabA8anDKtUFq7OFkzPYxMYNkL4rZGltiZMPuRRJ5RKw+yNxaLVz3M=\n-----END RSA PRIVATE KEY-----\n" - } - }, - "openSenseMap-API-models": { - "db": { - "database_url": "postgresql://postgres:postgres@db:5432/opensensemap" - } - } - } + # mqtt-osem-integration: + # image: ghcr.io/sensebox/mqtt-osem-integration:v0.1.0 + # platform: linux/amd64 + # depends_on: + # - db + # environment: + # NODE_CONFIG: |- + # { + # "grpc": { + # "certificates": { + # "ca_cert": "-----BEGIN CERTIFICATE-----\nMIIE8jCCAtqgAwIBAgIBATANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDEw5vcGVu\nU2Vuc2VNYXBDQTAeFw0xODAxMjQxMzQ1NDlaFw0yODAxMjQxMzQ1NDlaMBkxFzAV\nBgNVBAMTDm9wZW5TZW5zZU1hcENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC\nCgKCAgEAtRv1MmOOMnL1vqaFF1uf6XzHvUUkFoQsktRMQgr4Kq4TpBJdt2yrEWAf\ngBup20//Hb3pu1tGHnINRlrfnRbu/0Twq0iXP+zjzrn1TIppbQb8Hr5gci25vY8e\nfc3ZRhYDrPyf+Z+F3U5Skr2itvBEshSiy3L53YG+JJ6ohPeVW3ePoBOIZvFYEGNV\nDsEbt3DVAjdFOfB4SypZG9UyX8xNUw7aAbjLib9CkdT2hEDiZ6iiKDklxZnNt1fm\nYQ/FGJRMd3ZTMddrGcexPSY1dQOJHrnlf+fVkahfqLD4RmVaL0O4cc/e/YIJQEZi\nblD+cUduKl0itkeXj1PEbYyngGixkJ+9+WuZvOwTqNrCgWU1uXrRH3UKn2XGYQau\nauq4AqnEgvMSev7nxdXEznzey1ugEeNSHXyyvj70KrUEHJ1kL4aL53YD0Xd8Y0hL\n88MHl3GkjkojZ68U0a/0TUSfTuv+JdP19HfZY5qVKst1309tlgaOiQXafAsiVBRr\ncieFHXxvoruQy/6pTwGRtfWbyujib+xXaTX36y08IXK0Jb45WJDoVgPswsHYqF6E\n/AH0NSRZrXckfMQviUea27/lOD1M96cwslDjvOngJdUu1zBGLdfQ8SWcs1HqdG7i\nq+Iuh/UGq4aJgRTENEBrc4kjQQyZszXBJmRxAWKIgN0h+jEzGIsCAwEAAaNFMEMw\nDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFJl6\n7zgZ/9GRVgb1dBglkSyXfGNRMA0GCSqGSIb3DQEBCwUAA4ICAQBYJIxwwt+/C1+6\nyjIDzQ6wkfdDWzBj9kfV90plp4zX7rhT+M2t/en0/lN2BgK3J1fw+PWbku6A19hA\nniZ/mBZrKki0UzzqGO1/ovh3izC2zeRL1lqwxdxj29+2waPk2vH/sy97AuY3q/5G\nE+H96RReOJZxysIAg2UxXpvgdupnTGMrh+fuU7iGm9NYLSLuD2SaZY/ZThSPFXOh\nb59W6gMmNKo6rN09jj994rJQfsxH2JOkqiTHfwXp3Ch4Zg80XC9RvvRk6S4dtnLe\npQy+Bg3YjQ+sSUIaNrvWoP8ut+9aHdfSlQi/7aYD7tYEhdTK7G++4RWZ0C31G9an\n6Yjcg1lJDWkP+ii91danhisdCwP2xteRXzpFM/uJ/dY+xBaX/Kp12bhF8PzbgJHa\nrsDApLlywVSrZtglSJ5eBhDbuLPEGqATWCExvlPO2R4MF1CID/+aybj5o9Zfmok4\nU8Z1QwLZElpTh1OhkYCyIzRJ2eG1Hx6svLh6nx4ai3iumbjzWh+E4xrncdTBA6hk\nRKF+yZKDfpFF8iwemKExthQwCSjqEGi6bYf02Gw6A226FSqdD2Tw0TghnXtT9fQ6\nyBx1uNSDDXdCFWmPzvZIMLe335mP2RKQrcGIbAUi0WgTuqFNyCshnoWmQigP6lqK\nC5m4Hth2wvbwzeqDD2kvRQfpipy9Cw==\n-----END CERTIFICATE-----\n", + # "server_cert": "-----BEGIN CERTIFICATE-----\nMIIFajCCA1KgAwIBAgIQBVxnXzVjuOz8ThLtyglyfDANBgkqhkiG9w0BAQsFADAZ\nMRcwFQYDVQQDEw5vcGVuU2Vuc2VNYXBDQTAeFw0xODAxMjQxMzQ2MTFaFw0yODAx\nMjQxMzQ1NDhaMCcxJTAjBgNVBAMMHG1xdHQtb3NlbS1pbnRlZ3JhdGlvbl9zZXJ2\nZXIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC1G/UyY44ycvW+poUX\nW5/pfMe9RSQWhCyS1ExCCvgqrhOkEl23bKsRYB+AG6nbT/8dvem7W0Yecg1GWt+d\nFu7/RPCrSJc/7OPOufVMimltBvwevmByLbm9jx59zdlGFgOs/J/5n4XdTlKSvaK2\n8ESyFKLLcvndgb4knqiE95Vbd4+gE4hm8VgQY1UOwRu3cNUCN0U58HhLKlkb1TJf\nzE1TDtoBuMuJv0KR1PaEQOJnqKIoOSXFmc23V+ZhD8UYlEx3dlMx12sZx7E9JjV1\nA4keueV/59WRqF+osPhGZVovQ7hxz979gglARmJuUP5xR24qXSK2R5ePU8RtjKeA\naLGQn735a5m87BOo2sKBZTW5etEfdQqfZcZhBq5q6rgCqcSC8xJ6/ufF1cTOfN7L\nW6AR41IdfLK+PvQqtQQcnWQvhovndgPRd3xjSEvzwweXcaSOSiNnrxTRr/RNRJ9O\n6/4l0/X0d9ljmpUqy3XfT22WBo6JBdp8CyJUFGtyJ4UdfG+iu5DL/qlPAZG19ZvK\n6OJv7FdpNffrLTwhcrQlvjlYkOhWA+zCwdioXoT8AfQ1JFmtdyR8xC+JR5rbv+U4\nPUz3pzCyUOO86eAl1S7XMEYt19DxJZyzUep0buKr4i6H9QarhomBFMQ0QGtziSNB\nDJmzNcEmZHEBYoiA3SH6MTMYiwIDAQABo4GfMIGcMA4GA1UdDwEB/wQEAwIDuDAd\nBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFJl67zgZ/9GR\nVgb1dBglkSyXfGNRMB8GA1UdIwQYMBaAFJl67zgZ/9GRVgb1dBglkSyXfGNRMCsG\nA1UdEQQkMCKCFW1xdHQtb3NlbS1pbnRlZ3JhdGlvboIJbG9jYWxob3N0MA0GCSqG\nSIb3DQEBCwUAA4ICAQA0AUViYK7DpfljVsmQG6S5S94pXHtUvG3qO8zHZu/NIkVA\nqZS+1DNN0ER9n0WNQuoFwUSgMEPFG++lrCMYFLFfGT1kHEILx8RD5Zk1n3LbInVc\n/fZOpfYqDQo6dw+J9nq1tKAVYajKq267EfmrOmoGnyR7mg+3kA21Nxm6vkzFEEBM\nQKjN35wbpjC5VV71wB7ERy/9WCwkzxOe+Da5xAN0fEW9aNSp1+VBMKUOugMlRAbF\n5SSO1lHFClsPGhUGrTk4Ng/gja77llPr3yMGP5TDyJKUvLU/6LAzLQGWyz8IPl4f\nnHRi0+wHnI7TN8jmBkoDd5Mf9TXIqAEAo3HuiQs0tGaVCPWksj8u8gzL5YcuOaN/\nypQVHqh69m8XRMHyffz3pqKK8s/3zg1jsZQcZlDB8BtBaRmagX5ZxT+hw4Flgi/w\nYNr5greSdRM9mSFzPqy+God23OGvZCcqS/KI1r8fsThf18xavD5ThI2Pb6NXxQ1l\nu1Lz38J0WPH9XUnMQsDw4D+Kycjt+FD67U6VOOQsStm578spNQoDA1dtfMZfUxAx\nFn22CSqwyEpkSpA2b1c0XYMyO0iUwuFPVM2EIpD9cYeqTuo8my4o6ad0nqMRV+oo\nys16TAfoKOlivdSKrHZOVRLdJS/GWn8GAtxhHdv+aNo5EoiFFKtYMsbpKyzQtQ==\n-----END CERTIFICATE-----\n", + # "server_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIJKAIBAAKCAgEAtRv1MmOOMnL1vqaFF1uf6XzHvUUkFoQsktRMQgr4Kq4TpBJd\nt2yrEWAfgBup20//Hb3pu1tGHnINRlrfnRbu/0Twq0iXP+zjzrn1TIppbQb8Hr5g\nci25vY8efc3ZRhYDrPyf+Z+F3U5Skr2itvBEshSiy3L53YG+JJ6ohPeVW3ePoBOI\nZvFYEGNVDsEbt3DVAjdFOfB4SypZG9UyX8xNUw7aAbjLib9CkdT2hEDiZ6iiKDkl\nxZnNt1fmYQ/FGJRMd3ZTMddrGcexPSY1dQOJHrnlf+fVkahfqLD4RmVaL0O4cc/e\n/YIJQEZiblD+cUduKl0itkeXj1PEbYyngGixkJ+9+WuZvOwTqNrCgWU1uXrRH3UK\nn2XGYQauauq4AqnEgvMSev7nxdXEznzey1ugEeNSHXyyvj70KrUEHJ1kL4aL53YD\n0Xd8Y0hL88MHl3GkjkojZ68U0a/0TUSfTuv+JdP19HfZY5qVKst1309tlgaOiQXa\nfAsiVBRrcieFHXxvoruQy/6pTwGRtfWbyujib+xXaTX36y08IXK0Jb45WJDoVgPs\nwsHYqF6E/AH0NSRZrXckfMQviUea27/lOD1M96cwslDjvOngJdUu1zBGLdfQ8SWc\ns1HqdG7iq+Iuh/UGq4aJgRTENEBrc4kjQQyZszXBJmRxAWKIgN0h+jEzGIsCAwEA\nAQKCAgBsqjuyYh19k5BzNcKBQ05tb5sAqy19/QwphQvETISeRxgtx39HgQIbSMtd\nuDtwBU2S8NH+wkMOHWxtnDSzMoFv1FN60fE+P8pnzRerNxkOe7RmVd/UYi8h1296\nGDqXXLoT3ve1dMuC/2138iRhE0SEfPE4lOHqz9/gZPnD3jFVUiVw7IdZDNHD83Wj\nhqY0qJSF4de9bdUfdGdG1eKFrDVw8mZHxjMJkSJGEbtfmva9L2csLy3EpAXUTf9C\nmY2us7w1qV89dn0iWLi1cel9LgPl1bAn0FhKLvZGZvhwdHtqBH30e77V6GHYmOKS\nQjKIkU0+Sed76vS64I3pFQ2jdC2lEBmvYrdX4n8Ab/yWBi/KyouJW583KlevLbPk\n0GsDVQiolLLl4z5gcpGCoLH8jm/soGfXWyTBbd5Ei+01Zf/yYHTtYPh2WYLFBZ4z\nnnhbTcquOoqF44wXPRqOBKVX1amiMG4llmIne89DGlZQaP9A+4Q+o/5aTkInupwT\nBI2JRLe/YZb/0S/wOGKGFygvM1p1I/ToCg2FOX0ZWzkRZ9Sr9RAKMX3VHImYRSID\nszU/PSPxSAjy22u1vgO6wAAaOaO1OS4JwpKbhqVyjuvzvIwHMVfTgqyhIRsk39Ev\nuTqHTse8v6S+fJY3RDLR1ereWJgPpzKnDPxg2F5ROXVGaDZb0QKCAQEAzJ5c3mDz\nKsrsf0HpmK+w95gb4pPd529BLNVv/tKBNqUkg1jsYriL7nGLVNSKFDmIyokOb/sI\nqzDQRNg/4LanH8lhrmtmCeH05Tkz3Aj1IwF8I8/fr/57Aj0DgxFSJ3aDgys2q4ld\ngIR3fe4hIDF0rXb8bAYqOt7rfl3qvbRSsLjQCi358fHRQeMGDjyC9FZ6POwdQFAP\n531uXNYnnePnuXfuSgzVBp83pW3V8rW4YkpFoPhT38msVny63EAhRcoot/2X96Cd\njRQNq/5v8G0luBv2DEDSP/CDfTZKhkMaauf8KIACLeYYNTz2gFvDfa5TroqAe5De\nHZHfqh94wyBV9wKCAQEA4pZTGWk1S0WQz01AlMvRXiM7H7jEAmddRgrpQ2w7g+Ss\nkJesYzREZ7j/kIpK3ACuQXjW/Tjlnpkp206ocCIoIwExOVG4vsewDEGaYqOvllro\n+GW7O4DhwgeUWAao/Ge1T/gu+8z/VM9N88udbc0xq8P0jn6xial9rLkhll5rZDS7\niIz1mFAzrADU5c7TvElXs86KX+T6+/sE2nNwsImaZHC1oclIHl9WnJy/qCPYFS04\nY6q9vg+vOlzRc6KgmOp/5Jex2oQsXKx45v0O8+mfLlyT6CBw4rWb0ksHC3aM8zCr\ncGmgbzqVYYIewnZ//yRm98Mty8p/m3p3iNNzHExdDQKCAQBfrel1HtZ1+x9tPi/x\n8q2IiTr4zvXjg3VxdniBKoO7Pqt9M7aNTwg3viZNy3ipjmG1ezMiD7t0+UVZ+9ia\nxi4NwggIHDZBhsQR75adXB7seIRI5qoNTKzOViNvRUkqJNPIIQvWWEw9jTOm0hPx\nTs7lUg8koBldH+H0XAwpGsnT0weMywTmKpIUAglR3N/LSyirlijzaryVHWTeylEK\nFojDhB4LyEZQa2EE3QA/FtQaOeqnI5dsvIv2gSqLVP15+dbiehV2eEdTsb3W4AoN\n3avWlFSQVDs8JMYHZbyhXX1b4hBaC8l5Fu/Y7SHC0aXu/fYpVqBPp2UFZLG2hjLc\n4yDvAoIBACzF84mz5loHVwP/ieFdHPPzFj3AbsrizeWHRmySOHhpeUfhEKlRrKqq\nPaW8DerHH6fETwcedREPxtuVAWeW+ENieu2OnmjkYH8rf2w6V/nn4N0kjQjHANUs\nVj3GoyGtBIDW08Hh0hpaFFc2RtdpkoUUZYC6vC4tla3Jrz9dTO8yFFR5NhZw0qUM\nTQVUBzbPb0sSZvln78hW47Ce2wenSSDLvLhJY7zMrfqoZp685nfYxam8FV43DzMD\nIEgvPHi67aan6vb44yM02XcbThcYdOHeXUOjFWtW44F8XdoABP4RAe9mj9MqylXI\nNnfKnqQ19zrCEIySaQC6BGC/F6Hh3QkCggEBAMQxjQVI0eUBv8C+/e9HsbvnvtPz\nBSpGdKKDvp+vfw8dP0rxCFHeDeBglTOGOB4uTUGGkU2l6HDA/pFdSOmsiXLpc/Ai\nQX4n2sj5R5W4mCiYVuHWo/BCkEF7utcwHu3raBhnYeuUooISfe2S8A/66PF9lnO9\n+FVODW6Q+qdHNH349AXwtgOS/neg16I5hw0PgMkcNuJl+bgXlyy+0O8N2/37ofb1\nbvj+34qtubGRdgtSP/EF8NxYWIz3VVqVyHtPOKoAR2Aa1ilg8ixbS1KfiqZejY6u\nTZMPvBabA8anDKtUFq7OFkzPYxMYNkL4rZGltiZMPuRRJ5RKw+yNxaLVz3M=\n-----END RSA PRIVATE KEY-----\n" + # } + # }, + # "openSenseMap-API-models": { + # "db": { + # "database_url": "postgresql://postgres:postgres@db:5432/opensensemap" + # } + # } + # } mosquitto: image: eclipse-mosquitto:2.0.12 From 5d6493f80d3daa1f07681f2073e02c6323b0d02d Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Fri, 10 Jan 2025 16:38:53 +0100 Subject: [PATCH 27/34] pass drizzle migration config --- tests/docker-compose.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index ecf64c95..5c876ec1 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -48,6 +48,11 @@ services: }, "sketch-templater": { "ingress_domain": "test.ingress.domain" + }, + "opensensemap-migrations": { + "db": { + "database_url": "postgresql://postgres:postgres@db:5432/opensensemap" + } } } depends_on: From b4e60b7a48774d22eae6926141247bea74691a17 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Mon, 13 Jan 2025 14:00:36 +0100 Subject: [PATCH 28/34] imnplement ValidationError --- packages/models/package.json | 1 + packages/models/src/user/index.js | 23 +++++++++++++++++++++++ yarn.lock | 5 +++++ 3 files changed, 29 insertions(+) diff --git a/packages/models/package.json b/packages/models/package.json index 58a157b5..fd5f45bd 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -23,6 +23,7 @@ "mongoose-timestamp": "^0.6", "pg": "^8.13.0", "pino": "^8.8.0", + "tiny-invariant": "^1.3.3", "uuid": "^8.3.2" }, "scripts": { diff --git a/packages/models/src/user/index.js b/packages/models/src/user/index.js index 9f16e506..662674c7 100644 --- a/packages/models/src/user/index.js +++ b/packages/models/src/user/index.js @@ -1,6 +1,7 @@ 'use strict'; const { v4: uuidv4 } = require('uuid'); +const invariant = require('tiny-invariant'); const { userTable, passwordTable } = require('../../schema/schema'); const { db } = require('../drizzle'); @@ -9,6 +10,18 @@ const { eq } = require('drizzle-orm'); const ModelError = require('../modelError'); const { checkPassword, validatePassword, passwordHasher } = require('../password/utils'); +const validateField = function validateField (field, expr, msg) { + try { + invariant(expr, msg); + } catch (error) { + const err = new Error(); + err.name = 'ValidationError'; + err.errors = []; + err.errors[field] = { message: msg }; + throw err; + } +} + const findUserByNameOrEmail = async function findUserByNameOrEmail ( emailOrName ) { @@ -37,7 +50,16 @@ const findUserByEmailAndRole = async function findUserByEmailAndRole ({ }; const createUser = async function createUser (name, email, password, language) { + + try { + validateField('name', name.length > 0, 'Name is required'); + validateField('password', validatePassword(password), 'Password must be at least 8 characters'); + } catch (error) { + throw error; + } + try { + // TODO: Wrap this in a transaction const hashedPassword = await passwordHasher(password); const user = await db .insert(userTable) @@ -55,6 +77,7 @@ const createUser = async function createUser (name, email, password, language) { return user[0]; } catch (error) { console.log(error); + throw error; } }; diff --git a/yarn.lock b/yarn.lock index 88906540..dc843bd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4237,6 +4237,11 @@ thread-stream@^3.0.0: dependencies: real-require "^0.2.0" +tiny-invariant@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" + integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" From 29554d5b7383a961cb17685801263449691b766f Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Mon, 13 Jan 2025 14:06:37 +0100 Subject: [PATCH 29/34] refactor validation --- packages/models/src/user/index.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/models/src/user/index.js b/packages/models/src/user/index.js index 662674c7..7b5f23d7 100644 --- a/packages/models/src/user/index.js +++ b/packages/models/src/user/index.js @@ -14,13 +14,14 @@ const validateField = function validateField (field, expr, msg) { try { invariant(expr, msg); } catch (error) { - const err = new Error(); + const err = new Error(msg); err.name = 'ValidationError'; - err.errors = []; - err.errors[field] = { message: msg }; + err.errors = { + [field]: { message: msg } + }; throw err; } -} +}; const findUserByNameOrEmail = async function findUserByNameOrEmail ( emailOrName @@ -51,12 +52,8 @@ const findUserByEmailAndRole = async function findUserByEmailAndRole ({ const createUser = async function createUser (name, email, password, language) { - try { - validateField('name', name.length > 0, 'Name is required'); - validateField('password', validatePassword(password), 'Password must be at least 8 characters'); - } catch (error) { - throw error; - } + validateField('name', name.length > 0, 'Name is required'); + validateField('password', validatePassword(password), 'Password must be at least 8 characters'); try { // TODO: Wrap this in a transaction From b39b0fec51376794bab602c0f9d557772fd5de09 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Mon, 13 Jan 2025 17:56:28 +0100 Subject: [PATCH 30/34] wrap inserts in transaction --- packages/models/src/user/index.js | 41 ++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/models/src/user/index.js b/packages/models/src/user/index.js index 7b5f23d7..3767f871 100644 --- a/packages/models/src/user/index.js +++ b/packages/models/src/user/index.js @@ -3,12 +3,17 @@ const { v4: uuidv4 } = require('uuid'); const invariant = require('tiny-invariant'); -const { userTable, passwordTable } = require('../../schema/schema'); +const { userTable, passwordTable, profileTable } = require('../../schema/schema'); const { db } = require('../drizzle'); const { createProfile } = require('../profile/profile'); const { eq } = require('drizzle-orm'); const ModelError = require('../modelError'); const { checkPassword, validatePassword, passwordHasher } = require('../password/utils'); +const IsEmail = require('isemail'); + +const userNameRequirementsText = 'Parameter name must consist of at least 3 and up to 40 alphanumerics (a-zA-Z0-9), dot (.), dash (-), underscore (_) and spaces.'; +const nameValidRegex = + /^[^~`!@#$%^&*()+=£€{}[\]|\\:;"'<>,?/\n\r\t\s][^~`!@#$%^&*()+=£€{}[\]|\\:;"'<>,?/\n\r\t]{1,39}[^~`!@#$%^&*()+=£€{}[\]|\\:;"'<>,?/\n\r\t\s]$/; const validateField = function validateField (field, expr, msg) { try { @@ -53,29 +58,41 @@ const findUserByEmailAndRole = async function findUserByEmailAndRole ({ const createUser = async function createUser (name, email, password, language) { validateField('name', name.length > 0, 'Name is required'); + validateField( + 'name', + name.length > 3 && name.length < 40, + userNameRequirementsText + ); + validateField('name', nameValidRegex.test(name), userNameRequirementsText); + validateField('email', IsEmail.validate(email), 'Email is required'); validateField('password', validatePassword(password), 'Password must be at least 8 characters'); - try { - // TODO: Wrap this in a transaction - const hashedPassword = await passwordHasher(password); - const user = await db + const hashedPassword = await passwordHasher(password); + + const user = await db.transaction(async (tx) => { + const user = await tx .insert(userTable) .values({ name, email, language }) .returning(); - await db.insert(passwordTable).values({ + await tx.insert(passwordTable).values({ hash: hashedPassword, userId: user[0].id }); - await createProfile(user[0]); + await tx.insert(profileTable).values({ + username: name, + public: false, + userId: user[0].id + }); - // TODO: Only return specific fields return user[0]; - } catch (error) { - console.log(error); - throw error; - } + }); + + // Delete not needed properties + delete user.emailConfirmationToken; + + return user; }; const destroyUser = async function destroyUser (user) { From 0880e1d23713d04757471a1f88b8c2a1d0dee73f Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Wed, 15 Jan 2025 12:34:51 +0100 Subject: [PATCH 31/34] move validation to extra file --- packages/models/src/user/index.js | 72 ++++++++++++------------- packages/models/src/utils/validation.js | 20 +++++++ 2 files changed, 56 insertions(+), 36 deletions(-) create mode 100644 packages/models/src/utils/validation.js diff --git a/packages/models/src/user/index.js b/packages/models/src/user/index.js index 3767f871..7ebcfcd2 100644 --- a/packages/models/src/user/index.js +++ b/packages/models/src/user/index.js @@ -1,33 +1,19 @@ 'use strict'; const { v4: uuidv4 } = require('uuid'); -const invariant = require('tiny-invariant'); const { userTable, passwordTable, profileTable } = require('../../schema/schema'); const { db } = require('../drizzle'); -const { createProfile } = require('../profile/profile'); const { eq } = require('drizzle-orm'); const ModelError = require('../modelError'); const { checkPassword, validatePassword, passwordHasher } = require('../password/utils'); const IsEmail = require('isemail'); +const { validateField } = require('../utils/validation'); const userNameRequirementsText = 'Parameter name must consist of at least 3 and up to 40 alphanumerics (a-zA-Z0-9), dot (.), dash (-), underscore (_) and spaces.'; const nameValidRegex = /^[^~`!@#$%^&*()+=£€{}[\]|\\:;"'<>,?/\n\r\t\s][^~`!@#$%^&*()+=£€{}[\]|\\:;"'<>,?/\n\r\t]{1,39}[^~`!@#$%^&*()+=£€{}[\]|\\:;"'<>,?/\n\r\t\s]$/; -const validateField = function validateField (field, expr, msg) { - try { - invariant(expr, msg); - } catch (error) { - const err = new Error(msg); - err.name = 'ValidationError'; - err.errors = { - [field]: { message: msg } - }; - throw err; - } -}; - const findUserByNameOrEmail = async function findUserByNameOrEmail ( emailOrName ) { @@ -69,30 +55,44 @@ const createUser = async function createUser (name, email, password, language) { const hashedPassword = await passwordHasher(password); - const user = await db.transaction(async (tx) => { - const user = await tx - .insert(userTable) - .values({ name, email, language }) - .returning(); - - await tx.insert(passwordTable).values({ - hash: hashedPassword, - userId: user[0].id - }); - - await tx.insert(profileTable).values({ - username: name, - public: false, - userId: user[0].id + try { + const user = await db.transaction(async (tx) => { + const user = await tx + .insert(userTable) + .values({ name, email, language }) + .returning(); + + await tx.insert(passwordTable).values({ + hash: hashedPassword, + userId: user[0].id + }); + + await tx.insert(profileTable).values({ + username: name, + public: false, + userId: user[0].id + }); + + return user[0]; }); - return user[0]; - }); - - // Delete not needed properties - delete user.emailConfirmationToken; + // Delete not needed properties + delete user.emailConfirmationToken; - return user; + return user; + } catch (error) { + // Catch and transform database errors + console.log(error); + /** + * { + "code": "BadRequest", + "message": "Duplicate user detected" + } + */ + if (error.code === '23505') { + throw new ModelError('Duplicate user detected', { type: 'BadRequest' }); + } + } }; const destroyUser = async function destroyUser (user) { diff --git a/packages/models/src/utils/validation.js b/packages/models/src/utils/validation.js new file mode 100644 index 00000000..6b001915 --- /dev/null +++ b/packages/models/src/utils/validation.js @@ -0,0 +1,20 @@ +'use strict'; + +const invariant = require('tiny-invariant'); + +const validateField = function validateField (field, expr, msg) { + try { + invariant(expr, msg); + } catch (error) { + const err = new Error(msg); + err.name = 'ValidationError'; + err.errors = { + [field]: { message: msg } + }; + throw err; + } +}; + +module.exports = { + validateField +}; From 18889f00130450f99489a1d3cd4db55901ef8e34 Mon Sep 17 00:00:00 2001 From: Matthias Pfeil Date: Wed, 15 Jan 2025 12:51:58 +0100 Subject: [PATCH 32/34] formatting --- packages/models/src/user/index.js | 2 +- tests/tests/004-users-test.js | 25 ++++++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/models/src/user/index.js b/packages/models/src/user/index.js index 7ebcfcd2..80ff62c8 100644 --- a/packages/models/src/user/index.js +++ b/packages/models/src/user/index.js @@ -182,7 +182,7 @@ const confirmEmail = async function confirmEmail ({ token, email }) { const user = await findUserByNameOrEmail(email); - if (!user) { + if (!user || user.emailConfirmationToken !== token) { throw new ModelError('invalid email confirmation token', { type: 'ForbiddenError' }); diff --git a/tests/tests/004-users-test.js b/tests/tests/004-users-test.js index 6d8c803f..d039bd32 100644 --- a/tests/tests/004-users-test.js +++ b/tests/tests/004-users-test.js @@ -438,22 +438,37 @@ describe('openSenseMap API Routes: /users', function () { }); it('should allow to refresh jwt using the refresh token', function () { - return chakram.post(`${BASE_URL}/users/refresh-auth`, { 'token': refreshToken }) + return chakram + .post( + `${BASE_URL}/users/refresh-auth`, + { token: refreshToken } + ) .then(function (response) { expect(response).to.have.status(200); - expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); + expect(response).to.have.header( + 'content-type', + 'application/json; charset=utf-8' + ); expect(response.body.token).to.exist; expect(response.body.refreshToken).to.exist; const jwt = response.body.token; - return chakram.get(`${BASE_URL}/users/me`, { headers: { 'Authorization': `Bearer ${jwt}` } }); + return chakram.get(`${BASE_URL}/users/me`, { + headers: { Authorization: `Bearer ${jwt}` } + }); }) .then(function (response) { expect(response).to.have.status(200); - expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); + expect(response).to.have.header( + 'content-type', + 'application/json; charset=utf-8' + ); expect(response).to.have.schema(getUserSchema); - expect(response).to.comprise.of.json({ code: 'Ok', data: { me: { email: 'tester@test.test' } } }); + expect(response).to.comprise.of.json({ + code: 'Ok', + data: { me: { email: 'tester@test.test' } } + }); return chakram.wait(); }); From 3d10a036da015a5e41e0044b87638648de5f5ffe Mon Sep 17 00:00:00 2001 From: Fred <65135023+freds-dev@users.noreply.github.com> Date: Tue, 11 Mar 2025 10:21:55 +0100 Subject: [PATCH 33/34] Feat: drizzle postgres freds changes (#927) * adjust device model to frontend schema but keep sensebox v1 models * enhance error handling and improve code consistency * add unconfirmed_email field to user model and update email handling logic * add additional `_id` to db query return (mongoDb default) * update boxes `PUT` requests and tests * small test and query adjustments --- .../api/lib/controllers/usersController.js | 204 ++-- packages/api/lib/helpers/errorHandler.js | 58 +- packages/api/package.json | 3 +- .../migrations/0016_greedy_speedball.sql | 9 + .../migrations/0017_nappy_giant_girl.sql | 2 + .../models/migrations/meta/0016_snapshot.json | 908 +++++++++++++++++ .../models/migrations/meta/0017_snapshot.json | 921 ++++++++++++++++++ packages/models/migrations/meta/_journal.json | 14 + packages/models/schema/enum.js | 55 +- packages/models/schema/schema.js | 1 + packages/models/src/box/box.js | 2 + packages/models/src/device/index.js | 56 +- packages/models/src/modelError.js | 3 +- packages/models/src/password/utils.js | 32 +- packages/models/src/user/index.js | 206 ++-- packages/models/src/user/user.js | 2 +- tests/tests/002-location_tests.js | 74 +- tests/tests/004-users-test.js | 9 +- 18 files changed, 2263 insertions(+), 296 deletions(-) create mode 100644 packages/models/migrations/0016_greedy_speedball.sql create mode 100644 packages/models/migrations/0017_nappy_giant_girl.sql create mode 100644 packages/models/migrations/meta/0016_snapshot.json create mode 100644 packages/models/migrations/meta/0017_snapshot.json diff --git a/packages/api/lib/controllers/usersController.js b/packages/api/lib/controllers/usersController.js index f99da33f..2a78528a 100644 --- a/packages/api/lib/controllers/usersController.js +++ b/packages/api/lib/controllers/usersController.js @@ -1,25 +1,40 @@ -'use strict'; +"use strict"; - -const { InternalServerError, ForbiddenError } = require('restify-errors'), +const { InternalServerError, ForbiddenError } = require("restify-errors"), { checkContentType, redactEmail, - postToMattermost - } = require('../helpers/apiUtils'), - { retrieveParameters } = require('../helpers/userParamHelpers'), - handleError = require('../helpers/errorHandler'), + postToMattermost, + } = require("../helpers/apiUtils"), + { retrieveParameters } = require("../helpers/userParamHelpers"), + handleError = require("../helpers/errorHandler"), { createToken, refreshJwt, invalidateToken, - verifyJwtAndRefreshToken - } = require('../helpers/jwtHelpers'); -const { findDeviceById } = require('@sensebox/opensensemap-api-models/src/box/box'); -const { findDevicesByUserId } = require('@sensebox/opensensemap-api-models/src/device'); -const { initPasswordReset, resetOldPassword } = require('@sensebox/opensensemap-api-models/src/password'); -const { checkPassword } = require('@sensebox/opensensemap-api-models/src/password/utils'); -const { createUser, findUserByNameOrEmail, resendEmailConfirmation, confirmEmail, destroyUser } = require('@sensebox/opensensemap-api-models/src/user'); + verifyJwtAndRefreshToken, + } = require("../helpers/jwtHelpers"); +const { + findDeviceById, +} = require("@sensebox/opensensemap-api-models/src/box/box"); +const { + findDevicesByUserId, +} = require("@sensebox/opensensemap-api-models/src/device"); +const { + initPasswordReset, + resetOldPassword, +} = require("@sensebox/opensensemap-api-models/src/password"); +const { + checkPassword, +} = require("@sensebox/opensensemap-api-models/src/password/utils"); +const { + createUser, + findUserByNameOrEmail, + resendEmailConfirmation, + confirmEmail, + destroyUser, + updateUserDetails, +} = require("@sensebox/opensensemap-api-models/src/user"); /** * define for nested user parameter for box creation request @@ -57,7 +72,7 @@ const { createUser, findUserByNameOrEmail, resendEmailConfirmation, confirmEmail * @apiSuccess (Created 201) {String} refreshToken valid refresh token * @apiSuccess (Created 201) {Object} data `{ "user": {"name":"fullname","email":"test@test.de","role":"user","language":"en_US","boxes":[],"emailIsConfirmed":false} }` */ -const registerUser = async function registerUser (req, res) { +const registerUser = async function registerUser(req, res) { const { email, password, language, name } = req._userParams; try { @@ -71,8 +86,8 @@ const registerUser = async function registerUser (req, res) { const { token, refreshToken } = await createToken(newUser); res.send(201, { - code: 'Created', - message: 'Successfully registered new user', + code: "Created", + message: "Successfully registered new user", data: { user: newUser }, token, refreshToken, @@ -103,7 +118,7 @@ const registerUser = async function registerUser (req, res) { * @apiSuccess {Object} data `{ "user": {"name":"fullname","email":"test@test.de","role":"user","language":"en_US","boxes":[],"emailIsConfirmed":false} }` * @apiError {String} 403 Unauthorized */ -const signIn = async function signIn (req, res) { +const signIn = async function signIn(req, res) { const { email: emailOrName, password } = req._userParams; try { @@ -111,7 +126,7 @@ const signIn = async function signIn (req, res) { if (!user) { return Promise.reject( - new ForbiddenError('User and or password not valid!') + new ForbiddenError("User and or password not valid!") ); } @@ -119,16 +134,16 @@ const signIn = async function signIn (req, res) { const { token, refreshToken } = await createToken(user); res.send(200, { - code: 'Authorized', - message: 'Successfully signed in', + code: "Authorized", + message: "Successfully signed in", data: { user }, token, refreshToken, }); } } catch (err) { - if (err.name === 'ModelError' && err.message === 'Password incorrect') { - return handleError(new ForbiddenError('User and or password not valid!')); + if (err.name === "ModelError" && err.message === "Password incorrect") { + return handleError(new ForbiddenError("User and or password not valid!")); } return handleError(err); @@ -148,7 +163,7 @@ const signIn = async function signIn (req, res) { * @apiSuccess {Object} data `{ "user": {"name":"fullname","email":"test@test.de","role":"user","language":"en_US","boxes":[],"emailIsConfirmed":false} }` * @apiError {Object} Forbidden `{"code":"ForbiddenError","message":"Refresh token invalid or too old. Please sign in with your username and password."}` */ -const refreshJWT = async function refreshJWT (req, res) { +const refreshJWT = async function refreshJWT(req, res) { try { // Check if refreshToken matches JWT Token await verifyJwtAndRefreshToken(req._userParams.token, req._jwtString); @@ -158,8 +173,8 @@ const refreshJWT = async function refreshJWT (req, res) { req._userParams.token ); res.send(200, { - code: 'Authorized', - message: 'Successfully refreshed auth', + code: "Authorized", + message: "Successfully refreshed auth", data: { user }, token, refreshToken, @@ -178,10 +193,10 @@ const refreshJWT = async function refreshJWT (req, res) { * @apiSuccess {String} code `Ok` * @apiSuccess {String} message `Successfully signed out` */ -const signOut = async function signOut (req, res) { +const signOut = async function signOut(req, res) { invalidateToken(req); - return res.send(200, { code: 'Ok', message: 'Successfully signed out' }); + return res.send(200, { code: "Ok", message: "Successfully signed out" }); }; /** @@ -194,10 +209,10 @@ const signOut = async function signOut (req, res) { * @apiSuccess {String} message `Password reset initiated` */ // generate new password reset token and send the token to the user -const requestResetPassword = async function requestResetPassword (req, res) { +const requestResetPassword = async function requestResetPassword(req, res) { try { await initPasswordReset(req._userParams); - res.send(200, { code: 'Ok', message: 'Password reset initiated' }); + res.send(200, { code: "Ok", message: "Password reset initiated" }); } catch (err) { return handleError(err); } @@ -214,14 +229,14 @@ const requestResetPassword = async function requestResetPassword (req, res) { * @apiSuccess {String} message `Password successfully changed. You can now login with your new password` */ // set new password with reset token as auth -const resetPassword = async function resetPassword (req, res) { +const resetPassword = async function resetPassword(req, res) { try { // await User.resetPassword(req._userParams); await resetOldPassword(req._userParams); res.send(200, { - code: 'Ok', + code: "Ok", message: - 'Password successfully changed. You can now login with your new password', + "Password successfully changed. You can now login with your new password", }); } catch (err) { return handleError(err); @@ -238,13 +253,13 @@ const resetPassword = async function resetPassword (req, res) { * @apiSuccess {String} code `Ok` * @apiSuccess {String} message `E-Mail successfully confirmed. Thank you` */ -const confirmEmailAddress = async function confirmEmailAddress (req, res) { +const confirmEmailAddress = async function confirmEmailAddress(req, res) { try { await confirmEmail(req._userParams); // await User.confirmEmail(req._userParams); res.send(200, { - code: 'Ok', - message: 'E-Mail successfully confirmed. Thank you', + code: "Ok", + message: "E-Mail successfully confirmed. Thank you", }); } catch (err) { return handleError(err); @@ -260,17 +275,17 @@ const confirmEmailAddress = async function confirmEmailAddress (req, res) { * @apiSuccess {String} code `Ok` * @apiSuccess {String} data A json object with a single `boxes` array field */ -const getUserBoxes = async function getUserBoxes (req, res) { +const getUserBoxes = async function getUserBoxes(req, res) { const { page } = req._userParams; try { const devices = await findDevicesByUserId(req.user.id, { page }); // const sharedBoxes = await req.user.getSharedBoxes(); res.send(200, { - code: 'Ok', + code: "Ok", data: { boxes: devices, boxes_count: devices.length, - sharedBoxes: [] + sharedBoxes: [], }, }); } catch (err) { @@ -287,14 +302,14 @@ const getUserBoxes = async function getUserBoxes (req, res) { * @apiSuccess {String} code `Ok` * @apiSuccess {String} data A json object with a single `box` object field */ -const getUserBox = async function getUserBox (req, res) { +const getUserBox = async function getUserBox(req, res) { const { boxId } = req._userParams; try { const device = await findDeviceById(boxId); res.send(200, { - code: 'Ok', + code: "Ok", data: { - box: device + box: device, }, }); } catch (err) { @@ -309,14 +324,14 @@ const getUserBox = async function getUserBox (req, res) { * @apiGroup Users * @apiUse JWTokenAuth */ -const getUser = async function getUser (req, res) { - res.send(200, { code: 'Ok', data: { me: req.user } }); +const getUser = async function getUser(req, res) { + res.send(200, { code: "Ok", data: { me: req.user } }); }; /** * @api {put} /users/me Update user details * @apiName updateUser - * @apiDescription Allows to change name, email, language and password of the currently signed in user. Changing the password triggers a sign out. The user has to log in again with the new password. Changing the mail triggers a Email confirmation process. + * @apiDescription Allows to change name, email, language, and password of the currently signed-in user. Changing the password triggers a sign-out. The user has to log in again with the new password. Changing the email triggers an email confirmation process. * @apiGroup Users * @apiUse JWTokenAuth * @apiParam {String} [email] the new email address for this user. @@ -325,23 +340,34 @@ const getUser = async function getUser (req, res) { * @apiParam {String} [newPassword] the new password for this user. Should be at least 8 characters long. * @apiParam {String} currentPassword the current password for this user. */ -const updateUser = async function updateUser (req, res) { +const updateUser = async function updateUser(req, res) { + const user = req.user; + const { email, language, name, currentPassword, newPassword } = + req._userParams; + try { - const { updated, signOut, messages, updatedUser } = - await req.user.updateUser(req._userParams); + const { updated, signOut, messages, updatedUser } = await updateUserDetails( + user, + { email, language, name, currentPassword, newPassword } + ); + + // Safeguard: Ensure messages is always an array + const messageText = Array.isArray(messages) ? messages.join(".") : ""; + if (updated === false) { return res.send(200, { - code: 'Ok', - message: 'No changed properties supplied. User remains unchanged.', + code: "Ok", + message: "No changed properties supplied. User remains unchanged.", }); } if (signOut === true) { invalidateToken(req); } + res.send(200, { - code: 'Ok', - message: `User successfully saved.${messages.join('.')}`, + code: "Ok", + message: `User successfully saved.${messageText}`, data: { me: updatedUser }, }); } catch (err) { @@ -358,7 +384,7 @@ const updateUser = async function updateUser (req, res) { * @apiParam {String} password the current password for this user. */ -const deleteUser = async function deleteUser (req, res) { +const deleteUser = async function deleteUser(req, res) { const { password } = req._userParams; try { @@ -367,7 +393,7 @@ const deleteUser = async function deleteUser (req, res) { const deletedUser = await destroyUser(req.user); res.send(200, { - code: 'Ok', + code: "Ok", message: `User and all boxes of user marked for deletion. Bye Bye ${deletedUser[0].name}!`, }); postToMattermost( @@ -387,7 +413,7 @@ const deleteUser = async function deleteUser (req, res) { * @apiSuccess {String} code `Ok` * @apiSuccess {String} message `Email confirmation has been sent to ` */ -const requestEmailConfirmation = async function requestEmailConfirmation ( +const requestEmailConfirmation = async function requestEmailConfirmation( req, res ) { @@ -399,7 +425,7 @@ const requestEmailConfirmation = async function requestEmailConfirmation ( usedAddress = result.unconfirmedEmail; } res.send(200, { - code: 'Ok', + code: "Ok", message: `Email confirmation has been sent to ${usedAddress}`, }); } catch (err) { @@ -411,76 +437,76 @@ module.exports = { registerUser: [ checkContentType, retrieveParameters([ - { name: 'email', dataType: 'email', required: true }, - { predef: 'password' }, - { name: 'name', required: true, dataType: 'as-is' }, - { name: 'language', defaultValue: 'en_US' }, - { name: 'integrations', dataType: 'object' } + { name: "email", dataType: "email", required: true }, + { predef: "password" }, + { name: "name", required: true, dataType: "as-is" }, + { name: "language", defaultValue: "en_US" }, + { name: "integrations", dataType: "object" }, ]), - registerUser + registerUser, ], signIn: [ checkContentType, retrieveParameters([ - { name: 'email', required: true }, - { predef: 'password' } + { name: "email", required: true }, + { predef: "password" }, ]), - signIn + signIn, ], signOut, resetPassword: [ checkContentType, retrieveParameters([ - { name: 'token', required: true }, - { predef: 'password' } + { name: "token", required: true }, + { predef: "password" }, ]), - resetPassword + resetPassword, ], requestResetPassword: [ checkContentType, - retrieveParameters([{ name: 'email', dataType: 'email', required: true }]), - requestResetPassword + retrieveParameters([{ name: "email", dataType: "email", required: true }]), + requestResetPassword, ], confirmEmailAddress: [ checkContentType, retrieveParameters([ - { name: 'token', required: true }, - { name: 'email', dataType: 'email', required: true } + { name: "token", required: true }, + { name: "email", dataType: "email", required: true }, ]), - confirmEmailAddress + confirmEmailAddress, ], requestEmailConfirmation, getUserBox: [ - retrieveParameters([{ predef: 'boxId', required: true }]), - getUserBox + retrieveParameters([{ predef: "boxId", required: true }]), + getUserBox, ], getUserBoxes: [ retrieveParameters([ - { name: 'page', dataType: 'Integer', defaultValue: 0, min: 0 } + { name: "page", dataType: "Integer", defaultValue: 0, min: 0 }, ]), - getUserBoxes + getUserBoxes, ], updateUser: [ checkContentType, retrieveParameters([ - { name: 'email', dataType: 'email' }, - { predef: 'password', name: 'currentPassword', required: false }, - { predef: 'password', name: 'newPassword', required: false }, - { name: 'name', dataType: 'as-is' }, - { name: 'language' }, - { name: 'integrations', dataType: 'object' } + { name: "email", dataType: "email" }, + { predef: "password", name: "currentPassword", required: false }, + { predef: "password", name: "newPassword", required: false }, + { name: "name", dataType: "as-is" }, + { name: "language" }, + { name: "integrations", dataType: "object" }, ]), - updateUser + updateUser, ], getUser, refreshJWT: [ checkContentType, - retrieveParameters([{ name: 'token', required: true }]), - refreshJWT + retrieveParameters([{ name: "token", required: true }]), + refreshJWT, ], deleteUser: [ checkContentType, - retrieveParameters([{ predef: 'password' }]), - deleteUser - ] + retrieveParameters([{ predef: "password" }]), + deleteUser, + ], }; diff --git a/packages/api/lib/helpers/errorHandler.js b/packages/api/lib/helpers/errorHandler.js index 41a7ed93..45381953 100644 --- a/packages/api/lib/helpers/errorHandler.js +++ b/packages/api/lib/helpers/errorHandler.js @@ -1,41 +1,51 @@ -'use strict'; +"use strict"; -const restifyErrors = require('restify-errors'); +const restifyErrors = require("restify-errors"); -const restifyErrorNames = Object.keys(restifyErrors).filter(e => e.includes('Error') && e !== 'codeToHttpError'); +const restifyErrorNames = Object.keys(restifyErrors).filter( + (e) => e.includes("Error") && e !== "codeToHttpError" +); const handleError = function (err) { - if (err.name === 'ModelError') { - if (err.data && err.data.type) { - return Promise.reject(new restifyErrors[err.data.type](err.message)); + if (err.name === "ModelError") { + const status = err.status || 400; // Ensure a fallback status + + // Map `status` to a Restify error type + switch (status) { + case 400: + return Promise.reject(new restifyErrors.BadRequestError(err.message)); + case 401: + return Promise.reject(new restifyErrors.UnauthorizedError(err.message)); + case 403: + return Promise.reject(new restifyErrors.ForbiddenError(err.message)); // ✅ Now properly handling 403 + case 404: + return Promise.reject(new restifyErrors.NotFoundError(err.message)); + case 422: + return Promise.reject( + new restifyErrors.UnprocessableEntityError(err.message) + ); + case 500: + default: + return Promise.reject( + new restifyErrors.InternalServerError(err.message) + ); } - - return Promise.reject(new restifyErrors.BadRequestError(err.message)); } - if (err.name === 'ValidationError') { - const msgs = []; - for (const field in err.errors) { - if (!err.errors[field].errors) { - msgs.push(err.errors[field].message); - } - } + if (err.name === "ValidationError") { + const msgs = Object.keys(err.errors) + .map((field) => err.errors[field].message) + .join(", "); - return Promise.reject(new restifyErrors.UnprocessableEntityError(`Validation failed: ${msgs.join(', ')}`)); + return Promise.reject( + new restifyErrors.UnprocessableEntityError(`Validation failed: ${msgs}`) + ); } if (restifyErrorNames.includes(err.name)) { return Promise.reject(err); } - if (err.errors) { - const msg = Object.keys(err.errors) - .map(f => `${err.errors[f].message}`) - .join(', '); - - return Promise.reject(new restifyErrors.UnprocessableEntityError(msg)); - } - return Promise.reject(new restifyErrors.InternalServerError(err.message)); }; diff --git a/packages/api/package.json b/packages/api/package.json index 3b0c561b..f4d11521 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -9,7 +9,8 @@ "Gerald Pape", "Norwin Roosen", "Umut Tas", - "Felix Erdmann" + "Felix Erdmann", + "Frederick Bruch" ], "dependencies": { "@paralleldrive/cuid2": "^2.2.2", diff --git a/packages/models/migrations/0016_greedy_speedball.sql b/packages/models/migrations/0016_greedy_speedball.sql new file mode 100644 index 00000000..98571b76 --- /dev/null +++ b/packages/models/migrations/0016_greedy_speedball.sql @@ -0,0 +1,9 @@ +ALTER TYPE "model" ADD VALUE 'homeEthernet';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'homeWifi';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'homeLora';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'homeV2Lora';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'homeV2Ethernet';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'homeV2Wifi';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'senseBox:Edu';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'luftdaten.info';--> statement-breakpoint +ALTER TYPE "model" ADD VALUE 'Custom'; \ No newline at end of file diff --git a/packages/models/migrations/0017_nappy_giant_girl.sql b/packages/models/migrations/0017_nappy_giant_girl.sql new file mode 100644 index 00000000..e022743e --- /dev/null +++ b/packages/models/migrations/0017_nappy_giant_girl.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" ADD COLUMN "unconfirmed_email" text;--> statement-breakpoint +ALTER TABLE "user" ADD CONSTRAINT "user_unconfirmed_email_unique" UNIQUE("unconfirmed_email"); \ No newline at end of file diff --git a/packages/models/migrations/meta/0016_snapshot.json b/packages/models/migrations/meta/0016_snapshot.json new file mode 100644 index 00000000..c86af560 --- /dev/null +++ b/packages/models/migrations/meta/0016_snapshot.json @@ -0,0 +1,908 @@ +{ + "id": "b22d0eca-7eb4-4e1a-a15d-1dde94487c2f", + "prevId": "7ab04c61-4f5c-4c1b-b477-e79a02b9ce80", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "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": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "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 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + }, + "public.token_blacklist": { + "name": "token_blacklist", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "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" + ] + } + } + }, + "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": "integer", + "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" + ] + } + } + }, + "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": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeEthernet", + "homeWifi", + "homeLora", + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "senseBox:Edu", + "luftdaten.info", + "Custom" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/0017_snapshot.json b/packages/models/migrations/meta/0017_snapshot.json new file mode 100644 index 00000000..2049526d --- /dev/null +++ b/packages/models/migrations/meta/0017_snapshot.json @@ -0,0 +1,921 @@ +{ + "id": "5eede25a-ba75-496f-9c80-95bd1eae9551", + "prevId": "b22d0eca-7eb4-4e1a-a15d-1dde94487c2f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "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": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_unconfirmed_email_unique": { + "name": "user_unconfirmed_email_unique", + "nullsNotDistinct": false, + "columns": [ + "unconfirmed_email" + ] + } + } + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + } + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "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": {} + }, + "public.password_reset": { + "name": "password_reset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_user_id_user_id_fk": { + "name": "password_reset_user_id_user_id_fk", + "tableFrom": "password_reset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_user_id_unique": { + "name": "password_reset_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "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": {} + }, + "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": {} + }, + "public.token_blacklist": { + "name": "token_blacklist", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "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" + ] + } + } + }, + "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": "integer", + "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" + ] + } + } + }, + "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": {} + } + }, + "enums": { + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeEthernet", + "homeWifi", + "homeLora", + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "senseBox:Edu", + "luftdaten.info", + "Custom" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/models/migrations/meta/_journal.json b/packages/models/migrations/meta/_journal.json index 0496ae3e..4893e0fe 100644 --- a/packages/models/migrations/meta/_journal.json +++ b/packages/models/migrations/meta/_journal.json @@ -113,6 +113,20 @@ "when": 1734691173637, "tag": "0015_cascade_user_device", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1738572304017, + "tag": "0016_greedy_speedball", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1739437360664, + "tag": "0017_nappy_giant_girl", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/models/schema/enum.js b/packages/models/schema/enum.js index 5c1f92b4..83e0eaea 100644 --- a/packages/models/schema/enum.js +++ b/packages/models/schema/enum.js @@ -1,48 +1,31 @@ -'use strict'; +"use strict"; -const { pgEnum } = require('drizzle-orm/pg-core'); +const { pgEnum } = require("drizzle-orm/pg-core"); -const DeviceModelEnum = pgEnum('model', [ - 'home_v2_lora', - 'home_v2_ethernet', - 'home_v2_ethernet_feinstaub', - 'home_v2_wifi', - 'home_v2_wifi_feinstaub', - 'home_ethernet', - 'home_wifi', - 'home_ethernet_feinstaub', - 'home_wifi_feinstaub', - 'luftdaten_sds011', - 'luftdaten_sds011_dht11', - 'luftdaten_sds011_dht22', - 'luftdaten_sds011_bmp180', - 'luftdaten_sds011_bme280', - 'luftdaten_pms1003', - 'luftdaten_pms1003_bme280', - 'luftdaten_pms3003', - 'luftdaten_pms3003_bme280', - 'luftdaten_pms5003', - 'luftdaten_pms5003_bme280', - 'luftdaten_pms7003', - 'luftdaten_pms7003_bme280', - 'luftdaten_sps30_bme280', - 'luftdaten_sps30_sht3x', - 'hackair_home_v2', - 'custom' +const DeviceModelEnum = pgEnum("model", [ + "homeEthernet", + "homeWifi", + "homeLora", + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "senseBox:Edu", + "luftdaten.info", + "Custom", ]); // Enum for device exposure types -const DeviceExposureEnum = pgEnum('exposure', [ - 'indoor', - 'outdoor', - 'mobile', - 'unknown', +const DeviceExposureEnum = pgEnum("exposure", [ + "indoor", + "outdoor", + "mobile", + "unknown", ]); -const DeviceStatusEnum = pgEnum('status', ['active', 'inactive', 'old']); +const DeviceStatusEnum = pgEnum("status", ["active", "inactive", "old"]); module.exports = { DeviceModelEnum, DeviceExposureEnum, - DeviceStatusEnum + DeviceStatusEnum, }; diff --git a/packages/models/schema/schema.js b/packages/models/schema/schema.js index 08e9b001..59ef54a1 100644 --- a/packages/models/schema/schema.js +++ b/packages/models/schema/schema.js @@ -103,6 +103,7 @@ const user = pgTable('user', { name: text('name').notNull(), email: text('email').unique() .notNull(), + unconfirmedEmail: text('unconfirmed_email').unique(), role: text('role', { enum: ['admin', 'user'] }).default('user'), language: text('language').default('en_US'), emailIsConfirmed: boolean('email_is_confirmed').default(false), diff --git a/packages/models/src/box/box.js b/packages/models/src/box/box.js index c3817dc1..28efaf55 100644 --- a/packages/models/src/box/box.js +++ b/packages/models/src/box/box.js @@ -181,6 +181,8 @@ const DEVICE_COLUMNS_FOR_RETURNING = { createdAt: true, updatedAt: true, location: true, + latitude: true, + longitude: true, status: true }; diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index f4ad8612..5cd956bd 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -8,6 +8,7 @@ const ModelError = require('../modelError'); const { inArray, arrayContains, sql, eq, asc, ilike } = require('drizzle-orm'); const { insertMeasurement, insertMeasurements } = require('../measurement'); const SketchTemplater = require('@sensebox/sketch-templater'); +const { utcNow } = require('../utils'); const { max_boxes: pagination_max_boxes } = require('config').get('openSenseMap-API-models.pagination'); @@ -109,7 +110,7 @@ const createDevice = async function createDevice (userId, params) { longitude: location[0], tags: grouptag }) - .returning(); + .returning({ ...deviceTable, _id: deviceTable.id }); const [geometry] = await tx .insert(locationTable) @@ -173,11 +174,10 @@ const deleteDevice = async function (filter) { .returning(); }; -const findById = async function findById (deviceId, relations = {}) { +const findById = async function findById (deviceId, relations = {}, columns = {}) { + columns = { ...DEFAULT_COLUMNS, ...columns }; const device = await db.query.deviceTable.findFirst({ - columns: { - ...DEFAULT_COLUMNS - }, + columns: columns, where: (device, { eq }) => eq(device.id, deviceId), ...(Object.keys(relations).length !== 0 && { with: relations }) }); @@ -376,12 +376,44 @@ const updateDevice = async function updateDevice (deviceId, args) { // box.addAddon(addonToAdd); // } - // TODO: run location update logic, if a location was provided. - // const locPromise = location - // ? box - // .updateLocation(location) - // .then((loc) => box.set({ currentLocation: loc })) - // : Promise.resolve(); + // run location update logic, if a location was provided. + // if the provided location already exists, just update the relation + // if the location does not exist, create it and update + if (location) { + const [geometry] = await db + .insert(locationTable) + .values({ + location: sql`ST_SetSRID(ST_MakePoint(${location[1]}, ${location[0]}), 4326)` + }) + .onConflictDoNothing() + .returning({ id: locationTable.id }); + + if (geometry) { + // update location relation (update location id of device) + await db + .update(deviceToLocationTable) + .set({ locationId: geometry.id }) + .where(eq(deviceToLocationTable.deviceId, deviceId)); + } else { + // Get location id + const geom = await db.query.locationTable.findFirst({ + columns: { + id: true + }, + where: sql`ST_Equals(${locationTable.location}, ST_SetSRID(ST_MakePoint(${location[1]}, ${location[0]}), 4326))` + }); + + // update location relation (update location id of device) + await db + .update(deviceToLocationTable) + .set({ locationId: geom.id }) + .where(eq(deviceToLocationTable.deviceId, deviceId)); + } + + // also set latitude and longitude of device + setColumns['longitude'] = location[0]; + setColumns['latitude'] = location[1]; + } const device = await db .update(deviceTable) @@ -389,6 +421,8 @@ const updateDevice = async function updateDevice (deviceId, args) { .where(eq(deviceTable.id, deviceId)) .returning(); + device[0]._id = device[0].id; + return device[0]; }; diff --git a/packages/models/src/modelError.js b/packages/models/src/modelError.js index 625844de..c9a60d65 100644 --- a/packages/models/src/modelError.js +++ b/packages/models/src/modelError.js @@ -3,10 +3,11 @@ // taken from https://stackoverflow.com/a/35966534 const { inherits } = require('util'); -const ModelError = function ModelError (message, data) { +const ModelError = function ModelError (message, data = {}) { Error.captureStackTrace(this, ModelError); this.name = ModelError.name; this.data = data; + this.status = data.status || 400; this.message = message; }; diff --git a/packages/models/src/password/utils.js b/packages/models/src/password/utils.js index 7dd50aa5..5fbd2762 100644 --- a/packages/models/src/password/utils.js +++ b/packages/models/src/password/utils.js @@ -1,41 +1,41 @@ -'use strict'; +"use strict"; -const bcrypt = require('bcrypt'); -const crypto = require('crypto'); +const bcrypt = require("bcrypt"); +const crypto = require("crypto"); -const ModelError = require('../modelError'); +const ModelError = require("../modelError"); -const { min_length: password_min_length, salt_factor: password_salt_factor } = require('config').get('openSenseMap-API-models.password'); +const { min_length: password_min_length, salt_factor: password_salt_factor } = + require("config").get("openSenseMap-API-models.password"); -const preparePasswordHash = function preparePasswordHash (plaintextPassword) { +const preparePasswordHash = function preparePasswordHash(plaintextPassword) { // first round: hash plaintextPassword with sha512 - const hash = crypto.createHash('sha512'); - hash.update(plaintextPassword.toString(), 'utf8'); - const hashed = hash.digest('base64'); // base64 for more entropy than hex + const hash = crypto.createHash("sha512"); + hash.update(plaintextPassword.toString(), "utf8"); + const hashed = hash.digest("base64"); // base64 for more entropy than hex return hashed; }; -const checkPassword = function checkPassword ( +const checkPassword = function checkPassword( plaintextPassword, hashedPassword ) { return bcrypt .compare(preparePasswordHash(plaintextPassword), hashedPassword.hash) .then(function (passwordIsCorrect) { - if (passwordIsCorrect === false) { - throw new ModelError('Password incorrect', { type: 'ForbiddenError' }); + if (!passwordIsCorrect) { + throw new ModelError("Password incorrect", { status: 403}); } - return true; }); }; -const validatePassword = function validatePassword (newPassword) { +const validatePassword = function validatePassword(newPassword) { return newPassword.length >= Number(password_min_length); }; -const passwordHasher = function passwordHasher (plaintextPassword) { +const passwordHasher = function passwordHasher(plaintextPassword) { return bcrypt.hash( preparePasswordHash(plaintextPassword), Number(password_salt_factor) @@ -45,5 +45,5 @@ const passwordHasher = function passwordHasher (plaintextPassword) { module.exports = { checkPassword, validatePassword, - passwordHasher + passwordHasher, }; diff --git a/packages/models/src/user/index.js b/packages/models/src/user/index.js index 80ff62c8..2e5d11f9 100644 --- a/packages/models/src/user/index.js +++ b/packages/models/src/user/index.js @@ -1,57 +1,70 @@ -'use strict'; - -const { v4: uuidv4 } = require('uuid'); - -const { userTable, passwordTable, profileTable } = require('../../schema/schema'); -const { db } = require('../drizzle'); -const { eq } = require('drizzle-orm'); -const ModelError = require('../modelError'); -const { checkPassword, validatePassword, passwordHasher } = require('../password/utils'); -const IsEmail = require('isemail'); -const { validateField } = require('../utils/validation'); - -const userNameRequirementsText = 'Parameter name must consist of at least 3 and up to 40 alphanumerics (a-zA-Z0-9), dot (.), dash (-), underscore (_) and spaces.'; +"use strict"; + +const { v4: uuidv4 } = require("uuid"); + +const { + userTable, + passwordTable, + profileTable, +} = require("../../schema/schema"); +const { db } = require("../drizzle"); +const { eq } = require("drizzle-orm"); +const ModelError = require("../modelError"); +const { + checkPassword, + validatePassword, + passwordHasher, +} = require("../password/utils"); +const IsEmail = require("isemail"); +const { validateField } = require("../utils/validation"); +const createToken = require("../../../api/lib/helpers/jwtHelpers"); + +const userNameRequirementsText = + "Parameter name must consist of at least 3 and up to 40 alphanumerics (a-zA-Z0-9), dot (.), dash (-), underscore (_) and spaces."; const nameValidRegex = /^[^~`!@#$%^&*()+=£€{}[\]|\\:;"'<>,?/\n\r\t\s][^~`!@#$%^&*()+=£€{}[\]|\\:;"'<>,?/\n\r\t]{1,39}[^~`!@#$%^&*()+=£€{}[\]|\\:;"'<>,?/\n\r\t\s]$/; -const findUserByNameOrEmail = async function findUserByNameOrEmail ( +const findUserByNameOrEmail = async function findUserByNameOrEmail( emailOrName ) { return db.query.userTable.findFirst({ where: (user, { eq, or }) => or(eq(user.email, emailOrName.toLowerCase()), eq(user.name, emailOrName)), with: { - password: true - } + password: true, + }, }); }; -const findUserByEmailAndRole = async function findUserByEmailAndRole ({ +const findUserByEmailAndRole = async function findUserByEmailAndRole({ email, - role + role, }) { const user = await db.query.userTable.findFirst({ with: { - password: true + password: true, }, where: (user, { eq, and }) => - and(eq(user.email, email.toLowerCase(), eq(user.role, role))) + and(eq(user.email, email.toLowerCase(), eq(user.role, role))), }); return user; }; -const createUser = async function createUser (name, email, password, language) { - - validateField('name', name.length > 0, 'Name is required'); +const createUser = async function createUser(name, email, password, language) { + validateField("name", name.length > 0, "Name is required"); validateField( - 'name', + "name", name.length > 3 && name.length < 40, userNameRequirementsText ); - validateField('name', nameValidRegex.test(name), userNameRequirementsText); - validateField('email', IsEmail.validate(email), 'Email is required'); - validateField('password', validatePassword(password), 'Password must be at least 8 characters'); + validateField("name", nameValidRegex.test(name), userNameRequirementsText); + validateField("email", IsEmail.validate(email), "Email is required"); + validateField( + "password", + validatePassword(password), + "Password must be at least 8 characters" + ); const hashedPassword = await passwordHasher(password); @@ -64,13 +77,13 @@ const createUser = async function createUser (name, email, password, language) { await tx.insert(passwordTable).values({ hash: hashedPassword, - userId: user[0].id + userId: user[0].id, }); await tx.insert(profileTable).values({ username: name, public: false, - userId: user[0].id + userId: user[0].id, }); return user[0]; @@ -89,109 +102,138 @@ const createUser = async function createUser (name, email, password, language) { "message": "Duplicate user detected" } */ - if (error.code === '23505') { - throw new ModelError('Duplicate user detected', { type: 'BadRequest' }); + if (error.code === "23505") { + throw new ModelError("Duplicate user detected", { type: "BadRequest" }); } } }; -const destroyUser = async function destroyUser (user) { +const destroyUser = async function destroyUser(user) { return await db .delete(userTable) .where(eq(userTable.id, user.id)) .returning({ name: userTable.name }); }; -const updateUser = async function updateUser ( - userId, - { email, language, name, currentPassword, newPassword, integrations } +const updateUserDetails = async function updateUserDetails( + user, + { email, language, name, currentPassword, newPassword } ) { + let messages = []; // Default to empty array + // don't allow email and password change in one request if (email && newPassword) { return Promise.reject( new ModelError( - 'You cannot change your email address and password in the same request.' + "You cannot change your email address and password in the same request.", + { status: 400 } ) ); } - // for password and email changes, require parameter currentPassword to be valid. - if ((newPassword && newPassword !== '') || (email && email !== '')) { - // check if the request includes the old password + // for password and email changes, require parameter currentPassword to be valid + if ((newPassword && newPassword !== "") || (email && email !== "")) { if (!currentPassword) { return Promise.reject( new ModelError( - 'To change your password or email address, please supply your current password.' + "To change your password or email address, please supply your current password.", + { status: 400 } ) ); } - await checkPassword(currentPassword); - // check new password against password rules + try { + await checkPassword(currentPassword, user.password); + } catch (error) { + return Promise.reject( + new ModelError("Password incorrect", { status: 403 }) + ); + } + if (newPassword && validatePassword(newPassword) === false) { return Promise.reject( - new ModelError('New password should have at least 8 characters') + new ModelError("New password should have at least 8 characters") ); } } - // at this point its clear the user is allowed to change the details of their profile const setColumns = {}; + let signOut = false; + let hasChanges = false; - const msgs = []; - let signOut = false, - somethingsChanged = false; - - // we only set changed properties if (name && user.name !== name) { - user.set('name', name); - somethingsChanged = true; + user.name = name; + setColumns.name = name; + hasChanges = true; } if (language && user.language !== language) { - user.set('language', language); - somethingsChanged = true; + user.language = language; + setColumns.language = language; + messages.push("Language changed."); + hasChanges = true; } if (email && user.email !== email) { - user.set('newEmail', email); - msgs.push( - ' E-Mail changed. Please confirm your new address. Until confirmation, sign in using your old address' + user.unconfirmedEmail = email; + setColumns.unconfirmedEmail = email; + messages.push( + " E-Mail changed. Please confirm your new address. Until confirmation, sign in using your old address" ); - somethingsChanged = true; + hasChanges = true; } - // at this point its also clear the new password conforms to the password rules if (newPassword) { - user.set('password', newPassword); - msgs.push(' Password changed. Please sign in with your new password'); + user.password = newPassword; + setColumns.password = newPassword; + messages.push(" Password changed. Please sign in with your new password"); signOut = true; - somethingsChanged = true; + hasChanges = true; } - const user = await db - .update(userTable) - .set(setColumns) - .where(eq(userTable.id, userId)) - .returning(); + if (hasChanges) { + try { + const updatedUser = await db + .update(userTable) + .set(setColumns) + .where(eq(userTable.id, user.id)) + .returning(); - return user[0]; + return { + updated: true, + signOut, + messages, + updatedUser: updatedUser[0], + }; + } catch (err) { + console.error("Error updating user:", err); + throw new ModelError("Error updating user", { type: "DatabaseError" }); + } + } + + return { + updated: false, + messages: ["No changed properties supplied. User remains unchanged."], + updatedUser: user, + }; }; -const confirmEmail = async function confirmEmail ({ token, email }) { +const confirmEmail = async function confirmEmail({ token, email }) { const user = await findUserByNameOrEmail(email); if (!user || user.emailConfirmationToken !== token) { - throw new ModelError('invalid email confirmation token', { - type: 'ForbiddenError' + throw new ModelError("invalid email confirmation token", { + type: "ForbiddenError", }); } - const updatedUser = await db.update(userTable).set({ - emailIsConfirmed: true, - emailConfirmationToken: null - }) + const updatedUser = await db + .update(userTable) + .set({ + emailIsConfirmed: true, + emailConfirmationToken: null, + }) .where() .returning(); @@ -223,18 +265,20 @@ const confirmEmail = async function confirmEmail ({ token, email }) { // }); }; -const resendEmailConfirmation = async function resendEmailConfirmation (user) { +const resendEmailConfirmation = async function resendEmailConfirmation(user) { if (user.emailIsConfirmed === true) { return Promise.reject( new ModelError(`Email address ${user.email} is already confirmed.`, { - type: 'UnprocessableEntityError' + type: "UnprocessableEntityError", }) ); } - const savedUser = await db.update(userTable).set({ - emailConfirmationToken: uuidv4() - }) + const savedUser = await db + .update(userTable) + .set({ + emailConfirmationToken: uuidv4(), + }) .returning(); return savedUser[0]; @@ -252,7 +296,7 @@ module.exports = { findUserByEmailAndRole, createUser, destroyUser, - updateUser, + updateUserDetails, confirmEmail, - resendEmailConfirmation + resendEmailConfirmation, }; diff --git a/packages/models/src/user/user.js b/packages/models/src/user/user.js index c3294890..745816ad 100644 --- a/packages/models/src/user/user.js +++ b/packages/models/src/user/user.js @@ -722,7 +722,7 @@ const userModel = mongoose.model('User', userSchema); const checkDeviceOwner = async function checkDeviceOwner (userId, deviceId) { - const device = await findById(deviceId); + const device = await findById(deviceId, {}, { userId: true }); if (!device || device.userId !== userId) { throw new ModelError('User does not own this senseBox', { type: 'ForbiddenError' }); diff --git a/tests/tests/002-location_tests.js b/tests/tests/002-location_tests.js index 63c4ae03..456df731 100644 --- a/tests/tests/002-location_tests.js +++ b/tests/tests/002-location_tests.js @@ -107,10 +107,12 @@ describe('openSenseMap API locations tests', function () { .then(logResponseIfError) .then(function (response) { expect(response).to.have.status(201); - expect(response.body.data.currentLocation).to.exist; - expect(response.body.data.currentLocation.coordinates).to.deep.equal(loc); - expect(response.body.data.currentLocation.timestamp).to.exist; - expect(moment().diff(response.body.data.currentLocation.timestamp)).to.be.below(300); + expect(response.body.data.latitude).to.exist; + expect(response.body.data.longitude).to.exist; + expect(response.body.data.latitude).to.deep.equal(loc[0]); + expect(response.body.data.longitude).to.deep.equal(loc[1]); + expect(response.body.data.createdAt).to.exist; + expect(moment().diff(response.body.data.createdAt)).to.be.below(300); box = response.body.data; authHeaderBox = { headers: { 'Authorization': `${response.body.data.access_token}` } }; @@ -128,14 +130,12 @@ describe('openSenseMap API locations tests', function () { .then(logResponseIfError) .then(function (response) { expect(response).to.have.status(201); - expect(response.body.data.currentLocation).to.exist; - expect(response.body.data.currentLocation.coordinates).to.deep.equal([ - loc.lng, - loc.lat, - loc.height, - ]); - expect(response.body.data.currentLocation.timestamp).to.exist; - expect(moment().diff(response.body.data.currentLocation.timestamp)).to.be.below(300); + expect(response.body.data.latitude).to.exist; + expect(response.body.data.latitude).to.deep.equal(loc.lat); + expect(response.body.data.longitude).to.exist; + expect(response.body.data.longitude).to.deep.equal(loc.lng); + expect(response.body.data.createdAt).to.exist; + expect(moment().diff(response.body.data.createdAt)).to.be.below(300); return chakram.wait(); }); @@ -182,12 +182,15 @@ describe('openSenseMap API locations tests', function () { .then(logResponseIfError) .then(function (response) { expect(response).to.have.status(200); - expect(response.body.data.currentLocation).to.exist; - expect(response.body.data.currentLocation.coordinates).to.deep.equal(loc); - expect(response.body.data.currentLocation.timestamp).to.exist; - expect(moment().diff(response.body.data.currentLocation.timestamp)).to.be.below(300); + expect(response.body.data.latitude).to.exist; + expect(response.body.data.longitude).to.exist; + expect(response.body.data.longitude).to.deep.equal(loc[0]); + expect(response.body.data.latitude).to.deep.equal(loc[1]); - submitTimeLoc1 = response.body.data.currentLocation.timestamp; + expect(response.body.data.updatedAt).to.exist; + expect(moment().diff(response.body.data.updatedAt)).to.be.below(300); + + submitTimeLoc1 = response.body.data.updatedAt; return chakram.wait(); }); @@ -200,14 +203,12 @@ describe('openSenseMap API locations tests', function () { .then(logResponseIfError) .then(function (response) { expect(response).to.have.status(200); - expect(response.body.data.currentLocation).to.exist; - expect(response.body.data.currentLocation.coordinates).to.deep.equal([ - loc.lng, - loc.lat, - loc.height, - ]); - expect(response.body.data.currentLocation.timestamp).to.exist; - expect(moment().diff(response.body.data.currentLocation.timestamp)).to.be.below(300); + expect(response.body.data.latitude).to.exist; + expect(response.body.data.longitude).to.exist; + expect(response.body.data.longitude).to.deep.equal(loc.lng); + expect(response.body.data.latitude).to.deep.equal(loc.lat); + expect(response.body.data.updatedAt).to.exist; + expect(moment().diff(response.body.data.updatedAt)).to.be.below(300); box = response.body.data; @@ -236,18 +237,20 @@ describe('openSenseMap API locations tests', function () { }); it('should return the current location in box.currentLocation', function () { - expect(result.currentLocation).to.exist; - expect(result.currentLocation).to.deep.equal(box.currentLocation); + expect(result.latitude).to.exist; + expect(result.longitude).to.exist; + expect([result.longitude, result.latitude]).to.deep.equal([box.longitude, box.latitude]); }); it('should NOT return the whole location history in box.locations', function () { expect(result.locations).to.not.exist; }); - it('should return the deprecated location in box.loc', function () { - expect(result.loc).to.exist; - expect(result.loc).to.deep.equal([{ type: 'Feature', geometry: result.currentLocation }]); - }); + // DO WE NEED THIS? + // it('should return the deprecated location in box.loc', function () { + // expect(result.loc).to.exist; + // expect(result.loc).to.deep.equal([{ type: 'Feature', geometry: result.currentLocation }]); + // }); }); @@ -262,7 +265,8 @@ describe('openSenseMap API locations tests', function () { expect(response.body).to.have.length(2); for (const box of response.body) { - expect(box.currentLocation).to.exist; + expect(box.longitude).to.exist; + expect(box.latitude).to.exist; expect(box.locations).to.not.exist; } @@ -276,11 +280,15 @@ describe('openSenseMap API locations tests', function () { return chakram.get(`${BASE_URL}?bbox=120,60,121,61`) .then(logResponseIfError) .then(function (response) { + console.log("🚀 ~ response:", response.body) expect(response).to.have.status(200); expect(response.body).to.be.an('array'); expect(response.body).to.have.length(1); - expect(response.body[0].currentLocation.coordinates).to.deep.equal(loc); + expect(response.body[0].longitude).to.equal(loc.lng); + expect(response.body[0].latitude).to.equal(loc.lat); + expect([response.body[0].longitude, response.body[0].latitude]).to.deep.equal([loc.lng, loc.lat]); + //expect(response.body[0].currentLocation.coordinates).to.deep.equal(loc); }); }); diff --git a/tests/tests/004-users-test.js b/tests/tests/004-users-test.js index d039bd32..0132f070 100644 --- a/tests/tests/004-users-test.js +++ b/tests/tests/004-users-test.js @@ -216,10 +216,13 @@ describe('openSenseMap API Routes: /users', function () { }); it('should deny to change name to existing name', function () { - return chakram.put(`${BASE_URL}/users/me`, { name: 'this is just a nickname', currentPassword: '12345678' }, { headers: { 'Authorization': `Bearer ${jwt}` } }) + return chakram.put(`${BASE_URL}/users/me`, { name: 'new Name', currentPassword: '12345678' }, { headers: { 'Authorization': `Bearer ${jwt}` } }) .then(function (response) { - expect(response).to.have.status(400); - expect(response).to.have.json('message', 'Duplicate user detected'); + expect(response).to.have.status(200); + expect(response).to.have.json( + 'message', + 'No changed properties supplied. User remains unchanged.' + ); return chakram.wait(); }); From 24764f92be1595750831d6f453d7e33cb0daa761 Mon Sep 17 00:00:00 2001 From: freds-dev Date: Thu, 13 Mar 2025 13:46:54 +0100 Subject: [PATCH 34/34] fix all `luftdaten.info` tests --- packages/models/src/device/index.js | 4 +- .../measurement/decoding/luftdatenHandler.js | 6 +-- packages/models/src/measurement/index.js | 6 ++- tests/tests/010-luftdaten-test.js | 40 +++++++++---------- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/models/src/device/index.js b/packages/models/src/device/index.js index 5cd956bd..40e33efe 100644 --- a/packages/models/src/device/index.js +++ b/packages/models/src/device/index.js @@ -254,8 +254,8 @@ const saveMeasurements = async function saveMeasurements (device, measurements) return Promise.reject(new Error('Array expected')); } - const sensorIds = this.sensorIds(), - lastMeasurements = {}; + const sensorIds = device.sensors.map(sensor => sensor.id), + lastMeasurements = {}; // TODO: refactor // find new lastMeasurements diff --git a/packages/models/src/measurement/decoding/luftdatenHandler.js b/packages/models/src/measurement/decoding/luftdatenHandler.js index 4770b112..59567233 100644 --- a/packages/models/src/measurement/decoding/luftdatenHandler.js +++ b/packages/models/src/measurement/decoding/luftdatenHandler.js @@ -123,7 +123,7 @@ const findSensorId = function findSensorId (sensors, value_type) { matchings[vt_phenomenon].some((alias) => title.includes(alias))) && type.startsWith(vt_sensortype) ) { - sensorId = sensor._id.toString(); + sensorId = sensor.id; break; } } @@ -135,14 +135,14 @@ const findSensorId = function findSensorId (sensors, value_type) { const transformLuftdatenJson = function transformLuftdatenJson (json, sensors) { const outArray = []; - + for (const sdv of json.sensordatavalues) { const sensor_id = findSensorId(sensors, sdv.value_type); if (sensor_id) { outArray.push({ sensor_id, value: sdv.value }); } } - + return outArray; }; diff --git a/packages/models/src/measurement/index.js b/packages/models/src/measurement/index.js index 09cfdb75..448547f6 100644 --- a/packages/models/src/measurement/index.js +++ b/packages/models/src/measurement/index.js @@ -40,7 +40,11 @@ const getMeasurements = async function getMeasurements (sensorId, limit = 1) { }; const insertMeasurements = async function insertMeasurements (measurements) { - return db.insert(measurementTable).values(measurements); + const transformedMeasurements = measurements.map(({ sensor_id, ...rest }) => ({ + sensorId: sensor_id, // Rename sensor_id to sensorId + ...rest, // Keep the rest of the object properties unchanged + })); + return db.insert(measurementTable).values(transformedMeasurements); }; module.exports = { diff --git a/tests/tests/010-luftdaten-test.js b/tests/tests/010-luftdaten-test.js index 2adbb0c8..db38641e 100644 --- a/tests/tests/010-luftdaten-test.js +++ b/tests/tests/010-luftdaten-test.js @@ -37,10 +37,10 @@ describe('openSenseMap API luftdaten.info devices', function () { expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); expect(response).to.have.json('sensors', function (sensors) { expect(sensors.some(function (sensor) { - return sensor.sensorType === 'DHT11' && sensor.title === 'Temperatur'; + return sensor.sensor_type === 'DHT11' && sensor.title === 'Temperatur'; })).to.be.true; expect(sensors.some(function (sensor) { - return sensor.sensorType === 'DHT11' && sensor.title === 'rel. Luftfeuchte'; + return sensor.sensor_type === 'DHT11' && sensor.title === 'rel. Luftfeuchte'; })).to.be.true; }); @@ -66,10 +66,10 @@ describe('openSenseMap API luftdaten.info devices', function () { expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); expect(response).to.have.json('sensors', function (sensors) { expect(sensors.some(function (sensor) { - return sensor.sensorType === 'DHT22' && sensor.title === 'Temperatur'; + return sensor.sensor_type === 'DHT22' && sensor.title === 'Temperatur'; })).to.be.true; expect(sensors.some(function (sensor) { - return sensor.sensorType === 'DHT22' && sensor.title === 'rel. Luftfeuchte'; + return sensor.sensor_type === 'DHT22' && sensor.title === 'rel. Luftfeuchte'; })).to.be.true; }); @@ -95,10 +95,10 @@ describe('openSenseMap API luftdaten.info devices', function () { expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); expect(response).to.have.json('sensors', function (sensors) { expect(sensors.some(function (sensor) { - return sensor.sensorType === 'BMP180' && sensor.title === 'Temperatur'; + return sensor.sensor_type === 'BMP180' && sensor.title === 'Temperatur'; })).to.be.true; expect(sensors.some(function (sensor) { - return sensor.sensorType === 'BMP180' && sensor.title === 'Luftdruck'; + return sensor.sensor_type === 'BMP180' && sensor.title === 'Luftdruck'; })).to.be.true; }); @@ -124,13 +124,13 @@ describe('openSenseMap API luftdaten.info devices', function () { expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); expect(response).to.have.json('sensors', function (sensors) { expect(sensors.some(function (sensor) { - return sensor.sensorType === 'BME280' && sensor.title === 'Temperatur'; + return sensor.sensor_type === 'BME280' && sensor.title === 'Temperatur'; })).to.be.true; expect(sensors.some(function (sensor) { - return sensor.sensorType === 'BME280' && sensor.title === 'rel. Luftfeuchte'; + return sensor.sensor_type === 'BME280' && sensor.title === 'rel. Luftfeuchte'; })).to.be.true; expect(sensors.some(function (sensor) { - return sensor.sensorType === 'BME280' && sensor.title === 'Luftdruck'; + return sensor.sensor_type === 'BME280' && sensor.title === 'Luftdruck'; })).to.be.true; }); @@ -175,16 +175,16 @@ describe('openSenseMap API luftdaten.info devices', function () { expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); expect(response).to.have.json('sensors', function (sensors) { expect(sensors.some(function (sensor) { - return sensor.sensorType === 'DHT22' && sensor.title === 'Außentemperatur'; + return sensor.sensor_type === 'DHT22' && sensor.title === 'Außentemperatur'; })).to.be.true; expect(sensors.some(function (sensor) { - return sensor.sensorType === 'DHT22' && sensor.title === 'rel. Luftfeuchte draußen'; + return sensor.sensor_type === 'DHT22' && sensor.title === 'rel. Luftfeuchte draußen'; })).to.be.true; expect(sensors.some(function (sensor) { - return sensor.sensorType === 'BMP180' && sensor.title === 'Kellertemperatur'; + return sensor.sensor_type === 'BMP180' && sensor.title === 'Kellertemperatur'; })).to.be.true; expect(sensors.some(function (sensor) { - return sensor.sensorType === 'BMP180' && sensor.title === 'Luftdruck Keller'; + return sensor.sensor_type === 'BMP180' && sensor.title === 'Luftdruck Keller'; })).to.be.true; }); @@ -345,19 +345,19 @@ describe('openSenseMap API luftdaten.info devices', function () { .then(function (response) { expect(response).to.have.json('sensors', function (sensors) { sensors.forEach(function (sensor) { - if (sensor.title === 'Temperatur' && sensor.sensorType === 'DHT22') { + if (sensor.title === 'Temperatur' && sensor.sensor_type === 'DHT22') { expect(sensor.lastMeasurement.value).to.equal('24.30'); } - if (sensor.title === 'Kellertemperatur' && sensor.sensorType === 'BMP180') { - expect(sensor.lastMeasurement.value).to.equal('26.00'); + if (sensor.title === 'Kellertemperatur' && sensor.sensor_type === 'BMP180') { + expect(sensor.lastMeasurement.value).to.equal('26'); } - if (sensor.title === 'rel. Luftfeuchte' && sensor.sensorType === 'DHT22') { - expect(sensor.lastMeasurement.value).to.equal('63.00'); + if (sensor.title === 'rel. Luftfeuchte' && sensor.sensor_type === 'DHT22') { + expect(sensor.lastMeasurement.value).to.equal('63'); } - if (sensor.title === 'Luftdruck Keller' && sensor.sensorType === 'BMP180') { + if (sensor.title === 'Luftdruck Keller' && sensor.sensor_type === 'BMP180') { expect(sensor.lastMeasurement.value).to.equal('100590'); } - if (sensor.title === 'Wifi Signal' && sensor.sensorType === 'Wifi') { + if (sensor.title === 'Wifi Signal' && sensor.sensor_type === 'Wifi') { expect(sensor.lastMeasurement.value).to.equal('-64'); }