diff --git a/app/lib/user-service.server.ts b/app/lib/user-service.server.ts index 34a82983..2689b934 100644 --- a/app/lib/user-service.server.ts +++ b/app/lib/user-service.server.ts @@ -1,4 +1,6 @@ +import { eq } from "drizzle-orm"; import invariant from "tiny-invariant"; +import { v4 as uuidv4 } from "uuid"; import { revokeToken } from "./jwt"; import { type EmailValidation, @@ -8,6 +10,7 @@ import { validatePassword, validateUsername, } from "./user-service"; +import { drizzleClient } from "~/db.server"; import { createUser, deleteUserByEmail, @@ -18,7 +21,9 @@ import { updateUserPassword, verifyLogin, } from "~/models/user.server"; -import { type User } from "~/schema"; +import { passwordResetRequest, user, type User } from "~/schema"; + +const ONE_HOUR_MILLIS: number = 60 * 60 * 1000; /** * Register a new user with the database. @@ -229,3 +234,125 @@ export const deleteUser = async ( await revokeToken(user, jwtString); return (await deleteUserByEmail(user.email)).count > 0; }; + +/** + * Confirms a users email address by processing the token sent to the user and updating + * the profile when successful. + * @param emailConfirmationToken Token sent to the user via mail to the to-be-confirmed address + * @param emailToConfirm To-be-confirmed addresss + * @returns The updated user profile when successful or null when the specified user + * does not exist or the token is invalid. + */ +export const confirmEmail = async ( + emailConfirmationToken: string, + emailToConfirm: string, +): Promise => { + const u = await drizzleClient.query.user.findFirst({ + where: (user, { eq }) => eq(user.unconfirmedEmail, emailToConfirm), + }); + + if (!u || u.emailConfirmationToken !== emailConfirmationToken) return null; + + const updatedUser = await drizzleClient + .update(user) + .set({ + emailIsConfirmed: true, + emailConfirmationToken: null, + email: emailToConfirm, + unconfirmedEmail: null, + }) + .returning(); + + return updatedUser[0]; +}; + +/** + * Initiates a password request for the user with the given email address. + * Overwrites existing requests. + * @param email The email address to request a password reset for + */ +export const requestPasswordReset = async (email: string) => { + const user = await drizzleClient.query.user.findFirst({ + where: (user, { eq }) => eq(user.email, email.toLowerCase()), + }); + + if (!user) return; + + await drizzleClient + .insert(passwordResetRequest) + .values({ userId: user.id }) + .onConflictDoUpdate({ + target: passwordResetRequest.userId, + set: { + token: uuidv4(), + expiresAt: new Date(Date.now() + 12 * ONE_HOUR_MILLIS), // 12 hours from now + }, + }); + + // TODO send out email +}; + +/** + * Resets a users password using a specified passwordResetToken received through an email. + * @param passwordResetToken A token sent to the user via email to allow a password reset without being logged in. + * @param newPassword The new password for the user + * @returns "forbidden" if the user is not entitled to reset the password with the given parameters, + * "expired" if the {@link passwordResetToken} is expired, + * "invalid_password_format" if the specified new password does not comply with the password requirements, + * "success" if the password was successfuly set to the {@link newPassword} + */ +export const resetPassword = async ( + passwordResetToken: string, + newPassword: string, +): Promise<"forbidden" | "expired" | "invalid_password_format" | "success"> => { + const passwordReset = + await drizzleClient.query.passwordResetRequest.findFirst({ + where: (reset, { eq }) => eq(reset.token, passwordResetToken), + }); + + if (!passwordReset) return "forbidden"; + + if (Date.now() > passwordReset.expiresAt.getTime()) return "expired"; + + // Validate new Password + if (validatePassword(newPassword).isValid === false) + return "invalid_password_format"; + + const updated = await updateUserPassword(passwordReset.userId, newPassword); + + invariant(updated.length === 1); + // invalidate password reset token + await drizzleClient + .delete(passwordResetRequest) + .where(eq(passwordResetRequest.token, passwordResetToken)); + + // TODO: invalidate refreshToken and active accessTokens + + return "success"; +}; + +/** + * Resends the email confirmation for the given user again. + * This will reset existing email confirmation tokens and therefore + * make outstanding requests invalid. + * @param u The user to resend the email confirmation + * @returns "already_confirmed" if there is no email confirmation pending, + * else the updated user containing the new email confirmation token + */ +export const resendEmailConfirmation = async ( + u: User, +): Promise<"already_confirmed" | User> => { + if (u.emailIsConfirmed && u.unconfirmedEmail?.trim().length === 0) + return "already_confirmed"; + + const savedUser = await drizzleClient + .update(user) + .set({ + emailConfirmationToken: uuidv4(), + }) + .where(eq(user.id, u.id)) + .returning(); + + // TODO actually send the confirmation + return savedUser[0]; +}; diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 0dd2fc57..c1fe53e3 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import bcrypt from "bcryptjs"; import { eq } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; import { createProfile } from "./profile.server"; import { drizzleClient } from "~/db.server"; import { @@ -54,13 +55,14 @@ export const updateUserEmail = ( userToUpdate: User, newEmail: User["email"], ) => { - // TODO use email confirmation before changing the email over to the new one - // return drizzleClient - // .update(user) - // .set({ - // email: newEmail, - // }) - // .where(eq(user.id, userToUpdate.id)); + return drizzleClient + .update(user) + .set({ + unconfirmedEmail: newEmail, + emailConfirmationToken: uuidv4(), + }) + .where(eq(user.id, userToUpdate.id)); + // TODO send out email for confirmation }; export async function updateUserName( @@ -134,6 +136,7 @@ export async function createUser( name, email, language, + unconfirmedEmail: email, }) .returning(); diff --git a/app/routes/api.ts b/app/routes/api.ts index e084ce83..f7a0085a 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -71,18 +71,18 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { path: `users/register`, method: "POST", }, - // { - // path: `users/request-password-reset`, - // method: "POST", - // }, - // { - // path: `users/password-reset`, - // method: "POST", - // }, - // { - // path: `users/confirm-email`, - // method: "POST", - // }, + { + path: `users/request-password-reset`, + method: "POST", + }, + { + path: `users/password-reset`, + method: "POST", + }, + { + path: `users/confirm-email`, + method: "POST", + }, // { // path: `users/sign-in`, // method: "POST", @@ -157,10 +157,10 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { path: `users/me`, method: "DELETE", }, - // { - // path: `users/me/resend-email-confirmation`, - // method: "POST", - // }, + { + path: `users/me/resend-email-confirmation`, + method: "POST", + }, ], // management: [ // { diff --git a/app/routes/api.users.confirm-email.ts b/app/routes/api.users.confirm-email.ts new file mode 100644 index 00000000..2a3150b0 --- /dev/null +++ b/app/routes/api.users.confirm-email.ts @@ -0,0 +1,80 @@ +import { type ActionFunction, type ActionFunctionArgs } from "react-router"; +import { confirmEmail } from "~/lib/user-service.server"; + +export const action: ActionFunction = async ({ + request, +}: ActionFunctionArgs) => { + let formData = new FormData(); + try { + formData = await request.formData(); + } catch { + // Just continue, it will fail in the next check + // The try catch block handles an exception that occurs if the + // request was sent without x-www-form-urlencoded content-type header + } + + if ( + !formData.has("token") || + formData.get("token")?.toString().trim().length === 0 + ) + return Response.json( + { message: "No email confirmation token specified." }, + { + status: 400, + headers: { + "content-type": "application/json; charset=utf-8", + }, + }, + ); + + if ( + !formData.has("email") || + formData.get("email")?.toString().trim().length === 0 + ) + return Response.json( + { message: "No email address to confirm specified." }, + { + status: 400, + headers: { + "content-type": "application/json; charset=utf-8", + }, + }, + ); + + try { + const updatedUser = await confirmEmail( + formData.get("token")!.toString(), + formData.get("email")!.toString(), + ); + + if (updatedUser === null) + return Response.json( + { + code: "Forbidden", + message: "Invalid or expired confirmation token.", + }, + { + status: 403, + headers: { + "content-type": "application/json; charset=utf-8", + }, + }, + ); + + return Response.json( + { + code: "Ok", + message: "E-Mail successfully confirmed. Thank you", + }, + { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + }, + }, + ); + } catch (err) { + console.warn(err); + return new Response("Internal Server Error", { status: 500 }); + } +}; diff --git a/app/routes/api.users.me.resend-email-confirmation.ts b/app/routes/api.users.me.resend-email-confirmation.ts new file mode 100644 index 00000000..c6a5962d --- /dev/null +++ b/app/routes/api.users.me.resend-email-confirmation.ts @@ -0,0 +1,59 @@ +import { type ActionFunction, type ActionFunctionArgs } from "react-router"; +import { getUserFromJwt } from "~/lib/jwt"; +import { resendEmailConfirmation } from "~/lib/user-service.server"; + +export const action: ActionFunction = async ({ + request, +}: ActionFunctionArgs) => { + try { + const jwtResponse = await getUserFromJwt(request); + + if (typeof jwtResponse === "string") + return Response.json( + { + code: "Forbidden", + message: + "Invalid JWT authorization. Please sign in to obtain new JWT.", + }, + { + status: 403, + }, + ); + + const result = await resendEmailConfirmation(jwtResponse); + if (result === "already_confirmed") + return Response.json( + { + code: "Unprocessable Content", + message: `Email address ${jwtResponse.email} is already confirmed.`, + }, + { + status: 422, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }, + ); + + return Response.json( + { + code: "Ok", + message: `Email confirmation has been sent to ${result.unconfirmedEmail}`, + }, + { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }, + ); + } catch (err) { + console.warn(err); + return Response.json( + { + error: "Internal Server Error", + message: + "The server was unable to complete your request. Please try again later.", + }, + { + status: 500, + }, + ); + } +}; diff --git a/app/routes/api.users.me.ts b/app/routes/api.users.me.ts index d8e215d6..88225836 100644 --- a/app/routes/api.users.me.ts +++ b/app/routes/api.users.me.ts @@ -4,9 +4,8 @@ import { type LoaderFunction, type LoaderFunctionArgs, } from "react-router"; -import { getUserFromJwt, revokeToken } from "~/lib/jwt"; +import { getUserFromJwt } from "~/lib/jwt"; import { deleteUser, updateUserDetails } from "~/lib/user-service.server"; -import { verifyLogin } from "~/models/user.server"; import { type User } from "~/schema/user"; export const loader: LoaderFunction = async ({ diff --git a/app/routes/api.users.password-reset.ts b/app/routes/api.users.password-reset.ts new file mode 100644 index 00000000..97d89733 --- /dev/null +++ b/app/routes/api.users.password-reset.ts @@ -0,0 +1,98 @@ +import { type ActionFunction, type ActionFunctionArgs } from "react-router"; +import { resetPassword } from "~/lib/user-service.server"; + +export const action: ActionFunction = async ({ + request, +}: ActionFunctionArgs) => { + let formData = new FormData(); + try { + formData = await request.formData(); + } catch { + // Just continue, it will fail in the next check + // The try catch block handles an exception that occurs if the + // request was sent without x-www-form-urlencoded content-type header + } + + if ( + !formData.has("password") || + formData.get("password")?.toString().trim().length === 0 + ) + return Response.json( + { message: "No new password specified." }, + { + status: 400, + headers: { + "content-type": "application/json; charset=utf-8", + }, + }, + ); + + if ( + !formData.has("token") || + formData.get("token")?.toString().trim().length === 0 + ) + return Response.json( + { message: "No password reset token specified." }, + { + status: 400, + headers: { + "content-type": "application/json; charset=utf-8", + }, + }, + ); + + try { + const resetStatus = await resetPassword( + formData.get("token")!.toString(), + formData.get("password")!.toString(), + ); + + switch (resetStatus) { + case "forbidden": + case "expired": + return Response.json( + { + code: "Forbidden", + message: + resetStatus === "forbidden" + ? "Password reset for this user not possible" + : "Password reset token expired", + }, + { + status: 403, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }, + ); + case "invalid_password_format": + return Response.json( + { + code: "Bad Request", + message: + "Password must be at least ${password_min_length} characters.", + }, + { + status: 400, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }, + ); + case "success": + return Response.json( + { + code: "Ok", + message: + "Password successfully changed. You can now login with your new password", + }, + { + status: 400, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }, + ); + } + } catch (err) { + console.warn(err); + return Response.json("Internal Server Error", { + status: 500, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + } +}; diff --git a/app/routes/api.users.request-password-reset.ts b/app/routes/api.users.request-password-reset.ts new file mode 100644 index 00000000..12f0debc --- /dev/null +++ b/app/routes/api.users.request-password-reset.ts @@ -0,0 +1,46 @@ +import { type ActionFunction, type ActionFunctionArgs } from "react-router"; +import { requestPasswordReset } from "~/lib/user-service.server"; + +export const action: ActionFunction = async ({ + request, +}: ActionFunctionArgs) => { + let formData = new FormData(); + try { + formData = await request.formData(); + } catch { + // Just continue, it will fail in the next check + // The try catch block handles an exception that occurs if the + // request was sent without x-www-form-urlencoded content-type header + } + + if ( + !formData.has("email") || + formData.get("email")?.toString().trim().length === 0 + ) + return Response.json( + { message: "No email address specified." }, + { + status: 400, + headers: { + "content-type": "application/json; charset=utf-8", + }, + }, + ); + + try { + await requestPasswordReset(formData.get("email")!.toString()); + + // We don't want to leak valid/ invalid emails, so we confirm + // the initiation no matter what the return value above is + return Response.json( + { code: "Ok", message: "Password reset initiated" }, + { status: 200 }, + ); + } catch (err) { + console.warn(err); + return Response.json("Internal Server Error", { + status: 500, + headers: { "Content-Type": "application/json; charset: utf-8" }, + }); + } +}; diff --git a/app/schema/password.ts b/app/schema/password.ts index 141af112..54540b40 100644 --- a/app/schema/password.ts +++ b/app/schema/password.ts @@ -1,7 +1,10 @@ -import { type InferInsertModel, type InferSelectModel } from "drizzle-orm"; -import { pgTable, text } from "drizzle-orm/pg-core"; +import { type InferInsertModel, type InferSelectModel } from "drizzle-orm"; +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { v4 as uuidv4 } from "uuid"; import { user } from "./user"; +const ONE_HOUR_MILLIS: number = 60 * 60 * 1000; + /** * Table */ @@ -15,8 +18,31 @@ export const password = pgTable("password", { .notNull(), }); +export const passwordResetRequest = pgTable("password_reset_request", { + userId: text("user_id") + .unique() + .notNull() + .references(() => user.id, { + onDelete: "cascade", + }), + token: text("token") + .notNull() + .$defaultFn(() => uuidv4()), + expiresAt: timestamp("expires_at") + .notNull() + .$defaultFn( + () => new Date(Date.now() + 12 * ONE_HOUR_MILLIS), // 12 hours from now + ), +}); + /** * Types */ export type Password = InferSelectModel; -export type InsertPassword = InferInsertModel; \ No newline at end of file +export type InsertPassword = InferInsertModel; +export type PasswordResetRequest = InferSelectModel< + typeof passwordResetRequest +>; +export type InsertPasswordResetRequest = InferInsertModel< + typeof passwordResetRequest +>; diff --git a/app/schema/user.ts b/app/schema/user.ts index e9aabffa..5ca4c0ce 100644 --- a/app/schema/user.ts +++ b/app/schema/user.ts @@ -1,8 +1,13 @@ import { createId } from "@paralleldrive/cuid2"; -import { type InferInsertModel, type InferSelectModel, relations } from "drizzle-orm"; +import { + type InferInsertModel, + type InferSelectModel, + relations, +} from "drizzle-orm"; import { pgTable, boolean, text, timestamp } from "drizzle-orm/pg-core"; +import { v4 as uuidv4 } from "uuid"; import { device } from "./device"; -import { password } from "./password"; +import { password, passwordResetRequest } from "./password"; import { profile } from "./profile"; import { refreshToken } from "./refreshToken"; @@ -16,9 +21,13 @@ export const user = pgTable("user", { .$defaultFn(() => createId()), name: text("name").notNull(), email: text("email").unique().notNull(), + unconfirmedEmail: text("unconfirmed_email").unique(), role: text("role").$type<"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().notNull(), }); @@ -36,7 +45,11 @@ export const userRelations = relations(user, ({ one, many }) => ({ references: [profile.userId], }), devices: many(device), - refreshToken: many(refreshToken) + refreshToken: many(refreshToken), + passwordResetRequest: one(passwordResetRequest, { + fields: [user.id], + references: [passwordResetRequest.userId], + }), })); /** diff --git a/drizzle/0019_uneven_silver_surfer.sql b/drizzle/0019_uneven_silver_surfer.sql new file mode 100644 index 00000000..c9d350ac --- /dev/null +++ b/drizzle/0019_uneven_silver_surfer.sql @@ -0,0 +1,3 @@ +ALTER TABLE "user" ADD COLUMN "unconfirmed_email" text;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "email_confirmation_token" text;--> statement-breakpoint +ALTER TABLE "user" ADD CONSTRAINT "user_unconfirmed_email_unique" UNIQUE("unconfirmed_email"); \ No newline at end of file diff --git a/drizzle/0020_cloudy_misty_knight.sql b/drizzle/0020_cloudy_misty_knight.sql new file mode 100644 index 00000000..f8e252b1 --- /dev/null +++ b/drizzle/0020_cloudy_misty_knight.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS "password_reset_request" ( + "user_id" text NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp NOT NULL, + CONSTRAINT "password_reset_request_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "password_reset_request" ADD CONSTRAINT "password_reset_request_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/drizzle/meta/0019_snapshot.json b/drizzle/meta/0019_snapshot.json new file mode 100644 index 00000000..8f53f7a1 --- /dev/null +++ b/drizzle/meta/0019_snapshot.json @@ -0,0 +1,1082 @@ +{ + "id": "2083dc9a-c5d0-4805-b9c1-4331f4172065", + "prevId": "655c6d50-c7c2-4cee-99bd-7eb38e2257ad", + "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 + }, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_location": { + "name": "device_to_location", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_location_device_id_device_id_fk": { + "name": "device_to_location_device_id_device_id_fk", + "tableFrom": "device_to_location", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_to_location_location_id_location_id_fk": { + "name": "device_to_location_location_id_location_id_fk", + "tableFrom": "device_to_location", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_location_device_id_location_id_time_pk": { + "name": "device_to_location_device_id_location_id_time_pk", + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "uniqueConstraints": { + "device_to_location_device_id_location_id_time_unique": { + "name": "device_to_location_device_id_location_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.measurement": { + "name": "measurement", + "schema": "", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "measurement_location_id_location_id_fk": { + "name": "measurement_location_id_location_id_fk", + "tableFrom": "measurement", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": 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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": 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", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": 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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "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" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "location_index": { + "name": "location_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "location_location_unique": { + "name": "location_location_unique", + "nullsNotDistinct": false, + "columns": [ + "location" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entry": { + "name": "log_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_revocation": { + "name": "token_revocation", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "senseBox:Edu", + "luftdaten.info", + "Custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.measurement_10min": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_10min", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1day": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1day", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1hour": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1hour", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1month": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1month", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1year": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1year", + "schema": "public", + "isExisting": true, + "materialized": true + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0020_snapshot.json b/drizzle/meta/0020_snapshot.json new file mode 100644 index 00000000..7e7be36c --- /dev/null +++ b/drizzle/meta/0020_snapshot.json @@ -0,0 +1,1135 @@ +{ + "id": "9d89599f-78ef-4878-85a7-91a31bf984dc", + "prevId": "2083dc9a-c5d0-4805-b9c1-4331f4172065", + "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 + }, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_location": { + "name": "device_to_location", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_location_device_id_device_id_fk": { + "name": "device_to_location_device_id_device_id_fk", + "tableFrom": "device_to_location", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_to_location_location_id_location_id_fk": { + "name": "device_to_location_location_id_location_id_fk", + "tableFrom": "device_to_location", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_location_device_id_location_id_time_pk": { + "name": "device_to_location_device_id_location_id_time_pk", + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "uniqueConstraints": { + "device_to_location_device_id_location_id_time_unique": { + "name": "device_to_location_device_id_location_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.measurement": { + "name": "measurement", + "schema": "", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "measurement_location_id_location_id_fk": { + "name": "measurement_location_id_location_id_fk", + "tableFrom": "measurement", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": 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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_request": { + "name": "password_reset_request", + "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_request_user_id_user_id_fk": { + "name": "password_reset_request_user_id_user_id_fk", + "tableFrom": "password_reset_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_request_user_id_unique": { + "name": "password_reset_request_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": 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", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": 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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "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" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "location_index": { + "name": "location_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "location_location_unique": { + "name": "location_location_unique", + "nullsNotDistinct": false, + "columns": [ + "location" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entry": { + "name": "log_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_revocation": { + "name": "token_revocation", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "senseBox:Edu", + "luftdaten.info", + "Custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.measurement_10min": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_10min", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1day": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1day", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1hour": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1hour", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1month": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1month", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1year": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1year", + "schema": "public", + "isExisting": true, + "materialized": true + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1f39c50f..6dc906f7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -134,6 +134,20 @@ "when": 1748253218694, "tag": "0018_dazzling_tattoo", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1748350163331, + "tag": "0019_uneven_silver_surfer", + "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1748352273109, + "tag": "0020_cloudy_misty_knight", + "breakpoints": true } ] } \ No newline at end of file diff --git a/tests/routes/api.users.confirm-email.spec.ts b/tests/routes/api.users.confirm-email.spec.ts new file mode 100644 index 00000000..6ace94a8 --- /dev/null +++ b/tests/routes/api.users.confirm-email.spec.ts @@ -0,0 +1,36 @@ +import { type ActionFunctionArgs } from "react-router"; +import { BASE_URL } from "vitest.setup"; +import { action } from "~/routes/api.users.confirm-email"; + +describe("openSenseMap API Routes: /users", () => { + describe("/confirm-email", () => { + describe("POST", () => { + it("should deny email confirmation with wrong token", async () => { + const params = new URLSearchParams({ + token: "invalid_email-reset_token", + email: "tester@test.test", + }); + + const request = new Request(`${BASE_URL}/users/confirm-email`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + expect(response.status).toBe(403); + expect(response.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(body).toHaveProperty( + "message", + "Invalid or expired confirmation token.", + ); + }); + }); + }); +}); diff --git a/tests/routes/api.users.me.resend-email-confirmation.spec.ts b/tests/routes/api.users.me.resend-email-confirmation.spec.ts new file mode 100644 index 00000000..41df26c4 --- /dev/null +++ b/tests/routes/api.users.me.resend-email-confirmation.spec.ts @@ -0,0 +1,66 @@ +import { type ActionFunctionArgs } from "react-router"; +import { BASE_URL } from "vitest.setup"; +import { createToken } from "~/lib/jwt"; +import { registerUser } from "~/lib/user-service.server"; +import { deleteUserByEmail } from "~/models/user.server"; +import { action } from "~/routes/api.users.me.resend-email-confirmation"; +import { type User } from "~/schema"; + +const RESEND_EMAIL_USER = { + name: "resend some mails", + email: "test@resend.email", + password: "highlySecurePasswordForTesting", +}; + +describe("openSenseMap API Routes: /users", () => { + describe("/resend-email-confirmation", () => { + describe("POST", () => { + let jwt: string = ""; + + beforeAll(async () => { + const user = await registerUser( + RESEND_EMAIL_USER.name, + RESEND_EMAIL_USER.email, + RESEND_EMAIL_USER.password, + "en_US", + ); + const { token: t } = await createToken(user as User); + jwt = t; + }); + + it("should allow users to request a resend of the email confirmation", async () => { + // Request resend email confirmation + const resendRequest = new Request( + `${BASE_URL}/users/me/resend-email-confirmation`, + { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "", // No body required + }, + ); + + const resendResponse = (await action({ + request: resendRequest, + } as ActionFunctionArgs)) as Response; + + expect(resendResponse.status).toBe(200); + expect(resendResponse.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + const resendBody = await resendResponse.json(); + expect(resendBody).toMatchObject({ + code: "Ok", + message: `Email confirmation has been sent to ${RESEND_EMAIL_USER.email}`, + }); + }); + + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(RESEND_EMAIL_USER.email); + }); + }); + }); +}); diff --git a/tests/routes/api.users.password-reset.spec.ts b/tests/routes/api.users.password-reset.spec.ts new file mode 100644 index 00000000..597c46d6 --- /dev/null +++ b/tests/routes/api.users.password-reset.spec.ts @@ -0,0 +1,50 @@ +import { type ActionFunctionArgs } from "react-router"; +import { BASE_URL } from "vitest.setup"; +import { action } from "~/routes/api.users.password-reset"; + +describe("openSenseMap API Routes: /users", () => { + describe("/password-reset", () => { + describe("POST", () => { + it("should deny password request with wrong token", async () => { + const params = new URLSearchParams({ + password: "ignored_anyway", + token: "invalid_password-reset_token", + email: "tester@test.test", + }); + const request = new Request(`${BASE_URL}/users/password-reset`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + expect(response.status).toBe(403); + expect(response.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(body).toMatchObject({ + code: "Forbidden", + message: "Password reset for this user not possible", + }); + }); + it("should deny password change with empty token parameter", async () => { + const params = new URLSearchParams({ + password: "ignored_anyway", + token: " ", + email: "tester@test.test", + }); + const request = new Request(`${BASE_URL}/users/password-reset`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + expect(response.status).toBe(400); + }); + }); + }); +}); diff --git a/tests/routes/api.users.request-password-reset.spec.ts b/tests/routes/api.users.request-password-reset.spec.ts new file mode 100644 index 00000000..12952ca1 --- /dev/null +++ b/tests/routes/api.users.request-password-reset.spec.ts @@ -0,0 +1,50 @@ +import { type ActionFunctionArgs } from "react-router"; +import { BASE_URL } from "vitest.setup"; +import { registerUser } from "~/lib/user-service.server"; +import { deleteUserByEmail } from "~/models/user.server"; +import { action } from "~/routes/api.users.request-password-reset"; + +const VALID_USER = { + name: "password reset", + email: "password@reset.test", + password: "some super secure password", +}; + +describe("openSenseMap API Routes: /users", () => { + describe("/request-password-reset", () => { + beforeAll(async () => { + await registerUser( + VALID_USER.name, + VALID_USER.email, + VALID_USER.password, + "en_US", + ); + }); + + describe("POST", () => { + it("should allow to request a password reset token", async () => { + const params = new URLSearchParams(VALID_USER); + + const request = new Request( + `${BASE_URL}/users/request-password-reset`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }, + ); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + + expect(response.status).toBe(200); + }); + }); + + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(VALID_USER.email); + }); + }); +}); diff --git a/tests/routes/api.users.spec.ts b/tests/routes/api.users.spec.ts index 7b328395..8a73d619 100644 --- a/tests/routes/api.users.spec.ts +++ b/tests/routes/api.users.spec.ts @@ -562,62 +562,6 @@ describe("openSenseMap API Routes: /users", () => { // }); // }); - // it('should allow to request a password reset token', () => { - // return chakram.post(`${BASE_URL}/users/request-password-reset`, valid_user) - // .then(function (response) { - // expect(response).to.have.status(200); - - // return chakram.wait(); - // }); - // }); - - // it('should deny password request with wrong token', () => { - // return chakram.post(`${BASE_URL}/users/password-reset`, { password: 'ignored_anyway', token: 'invalid_password-reset_token', email: 'tester@test.test' }) - // .then(function (response) { - // expect(response).to.have.status(403); - // expect(response).to.have.json({ - // code: 'Forbidden', - // message: 'Password reset for this user not possible' - // }); - - // return chakram.wait(); - // }); - // }); - - // it('should deny password change with empty token parameter', () => { - // return chakram.post(`${BASE_URL}/users/password-reset`, { password: 'ignored_anyway', token: ' ', email: 'tester@test.test' }) - // .then(function (response) { - // expect(response).to.have.status(400); - // }); - // }); - - // it('should deny email confirmation with wrong token', () => { - // return chakram.post(`${BASE_URL}/users/confirm-email`, { token: 'invalid_password-reset_token', email: 'tester@test.test' }) - // .then(function (response) { - // expect(response).to.have.status(403); - - // return chakram.wait(); - // }); - // }); - - // it('should allow users request a resend of the email confirmation', () => { - // return chakram.post(`${BASE_URL}/users/register`, { name: 'mrtest', email: 'tester4@test.test', password: '12345678' }) - // .then(function (response) { - // expect(response).to.have.status(201); - // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); - // expect(response.body.token).to.exist; - - // return chakram.post(`${BASE_URL}/users/me/resend-email-confirmation`, {}, { headers: { 'Authorization': `Bearer ${response.body.token}` } }); - // }) - // .then(function (response) { - // expect(response).to.have.status(200); - // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); - // expect(response).to.comprise.of.json({ code: 'Ok', message: 'Email confirmation has been sent to tester4@test.test' }); - - // return chakram.wait(); - // }); - // }); - // it('should deny to register with multiline username', () => { // return chakram.post(`${BASE_URL}/users/register`, { // name: `multi