From eaf0fb5956da37c6c08d0ff3d8ada2722935013b Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 16 Jun 2026 14:39:17 +0200 Subject: [PATCH 01/26] feat: add newsletter opt in field --- app/db/schema/user.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/db/schema/user.ts b/app/db/schema/user.ts index c7fa39c7..7b1366cf 100644 --- a/app/db/schema/user.ts +++ b/app/db/schema/user.ts @@ -30,6 +30,7 @@ export const user = pgTable('user', { role: text('role').$type<'admin' | 'user'>().default('user'), language: text('language').default('en_US'), emailIsConfirmed: boolean('email_is_confirmed').default(false), + newsletterOptIn: boolean('newsletter_opt_in').default(false).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), acceptedTosVersionId: text('accepted_tos_version_id').references( From 09700c2a442519410bb01e93be156677d30dd2a7 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 16 Jun 2026 14:39:36 +0200 Subject: [PATCH 02/26] feat: migration --- .../drizzle/0044_greedy_golden_guardian.sql | 1 + app/db/drizzle/meta/0044_snapshot.json | 1647 +++++++++++++++++ app/db/drizzle/meta/_journal.json | 7 + 3 files changed, 1655 insertions(+) create mode 100644 app/db/drizzle/0044_greedy_golden_guardian.sql create mode 100644 app/db/drizzle/meta/0044_snapshot.json diff --git a/app/db/drizzle/0044_greedy_golden_guardian.sql b/app/db/drizzle/0044_greedy_golden_guardian.sql new file mode 100644 index 00000000..f3bb30fe --- /dev/null +++ b/app/db/drizzle/0044_greedy_golden_guardian.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "newsletter_opt_in" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/app/db/drizzle/meta/0044_snapshot.json b/app/db/drizzle/meta/0044_snapshot.json new file mode 100644 index 00000000..88e9e286 --- /dev/null +++ b/app/db/drizzle/meta/0044_snapshot.json @@ -0,0 +1,1647 @@ +{ + "id": "ff9187c2-a044-421b-91bc-199450bfd5da", + "prevId": "07740930-4c95-45f7-8d50-1115b8954086", + "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 + }, + "website": { + "name": "website", + "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 + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "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, + "default": "'custom'" + }, + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "orphaned_at": { + "name": "orphaned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "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": "cascade" + } + }, + "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": true, + "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 + }, + "display_name": { + "name": "display_name", + "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": true + } + }, + "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_user_id_unique": { + "name": "profile_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "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": true + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "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 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "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 + }, + "theme_preference": { + "name": "theme_preference", + "type": "theme_preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "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 + }, + "newsletter_opt_in": { + "name": "newsletter_opt_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "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()" + }, + "accepted_tos_version_id": { + "name": "accepted_tos_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_tos_at": { + "name": "accepted_tos_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_accepted_tos_version_id_tos_version_id_fk": { + "name": "user_accepted_tos_version_id_tos_version_id_fk", + "tableFrom": "user", + "tableTo": "tos_version", + "columnsFrom": [ + "accepted_tos_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_name_unique": { + "name": "user_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "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 + }, + "public.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "box_id": { + "name": "box_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": 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": { + "claim_expires_at_idx": { + "name": "claim_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "claim_box_id_device_id_fk": { + "name": "claim_box_id_device_id_fk", + "tableFrom": "claim", + "tableTo": "device", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_box_id": { + "name": "unique_box_id", + "nullsNotDistinct": false, + "columns": [ + "box_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_key": { + "name": "service_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_slug_unique": { + "name": "integration_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tos_user_state": { + "name": "tos_user_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tos_version_id": { + "name": "tos_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tos_user_state_user_idx": { + "name": "tos_user_state_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tos_user_state_user_id_user_id_fk": { + "name": "tos_user_state_user_id_user_id_fk", + "tableFrom": "tos_user_state", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tos_user_state_tos_version_id_tos_version_id_fk": { + "name": "tos_user_state_tos_version_id_tos_version_id_fk", + "tableFrom": "tos_user_state", + "tableTo": "tos_version", + "columnsFrom": [ + "tos_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tos_user_state_user_id_tos_version_id_pk": { + "name": "tos_user_state_user_id_tos_version_id_pk", + "columns": [ + "user_id", + "tos_version_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tos_version": { + "name": "tos_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "effective_from": { + "name": "effective_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accept_by": { + "name": "accept_by", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tos_version_effective_from_idx": { + "name": "tos_version_effective_from_idx", + "columns": [ + { + "expression": "effective_from", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tos_version_accept_by_idx": { + "name": "tos_version_accept_by_idx", + "columns": [ + { + "expression": "accept_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tos_version_version_unique": { + "name": "tos_version_version_unique", + "nullsNotDistinct": false, + "columns": [ + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_token": { + "name": "action_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "action_token_user_purpose_uq": { + "name": "action_token_user_purpose_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_token_expires_at_idx": { + "name": "action_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "action_token_user_id_user_id_fk": { + "name": "action_token_user_id_user_id_fk", + "tableFrom": "action_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "action_token_token_hash_unique": { + "name": "action_token_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "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", + "homeEthernet", + "homeWifi", + "homeEthernetFeinstaub", + "homeWifiFeinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "hackair_home_v2", + "senseBox:Edu", + "luftdaten.info", + "custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + }, + "public.theme_preference": { + "name": "theme_preference", + "schema": "public", + "values": [ + "light", + "dark", + "system" + ] + } + }, + "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/app/db/drizzle/meta/_journal.json b/app/db/drizzle/meta/_journal.json index 350fa5c6..8dd198e7 100644 --- a/app/db/drizzle/meta/_journal.json +++ b/app/db/drizzle/meta/_journal.json @@ -309,6 +309,13 @@ "when": 1781082106170, "tag": "0043_flowery_bedlam", "breakpoints": true + }, + { + "idx": 44, + "version": "7", + "when": 1781601203969, + "tag": "0044_greedy_golden_guardian", + "breakpoints": true } ] } \ No newline at end of file From 1bde19d31a28365b8a73ca137b61e215b0416f3c Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 16 Jun 2026 15:16:40 +0200 Subject: [PATCH 03/26] feat: add newsletter opt in to registration, use checkbox component --- app/db/models/user.server.ts | 11 +++++++++- app/lib/request-parsing.ts | 8 ++++++- app/routes/api.users.register.ts | 2 ++ app/routes/explore.register.tsx | 19 +++++++++++++---- app/services/user-service.server.ts | 33 ++++++++++++++++++++++++++++- 5 files changed, 66 insertions(+), 7 deletions(-) diff --git a/app/db/models/user.server.ts b/app/db/models/user.server.ts index 75b8e90a..c7fbbcc2 100644 --- a/app/db/models/user.server.ts +++ b/app/db/models/user.server.ts @@ -121,13 +121,16 @@ export async function updateUserPassword( type UpdateUserPreferencesArgs = { language?: User['language'] themePreference?: ThemePreference + newsletterOptIn?: User['newsletterOptIn'] } export async function updateUserPreferencesById( id: User['id'], args: UpdateUserPreferencesArgs, ) { - const values: Partial> = {} + const values: Partial< + Pick + > = {} if (args.language !== undefined) { values.language = args.language @@ -137,6 +140,10 @@ export async function updateUserPreferencesById( values.themePreference = args.themePreference } + if (args.newsletterOptIn !== undefined) { + values.newsletterOptIn = args.newsletterOptIn + } + if (Object.keys(values).length === 0) { throw new Error('No user preference fields provided') } @@ -234,6 +241,7 @@ export async function createUser( language: User['language'], password: string, tosVersionId?: string, + newsletterOptIn = false, ) { const hashedPassword = await bcrypt.hash(preparePasswordHash(password), 13) // make salt_factor configurable oSeM API uses 13 by default @@ -247,6 +255,7 @@ export async function createUser( unconfirmedEmail: email, acceptedTosVersionId: tosVersionId, acceptedTosAt: new Date(), + newsletterOptIn, }) .returning() await t.insert(passwordTable).values({ diff --git a/app/lib/request-parsing.ts b/app/lib/request-parsing.ts index feadd6e7..fd030504 100644 --- a/app/lib/request-parsing.ts +++ b/app/lib/request-parsing.ts @@ -37,6 +37,10 @@ export async function parseRequestData( } } +function parseBoolean(value: unknown): boolean { + return value === true || value === 'true' || value === 'on' +} + /** * Convenience function to parse user registration data with field mapping. * Handles both JSON and form data formats with backward compatibility. @@ -50,6 +54,7 @@ export async function parseUserRegistrationData(request: Request): Promise<{ password: string language: string tosAccepted: boolean + newsletterOptIn: boolean }> { const data = await parseRequestData(request) @@ -58,7 +63,8 @@ export async function parseUserRegistrationData(request: Request): Promise<{ email: data.email || '', password: data.password || '', language: data.language || 'en_US', - tosAccepted: data.tosAccepted || false, + tosAccepted: parseBoolean(data.tosAccepted), + newsletterOptIn: parseBoolean(data.newsletterOptIn ?? data.newsletter_optIn), } } diff --git a/app/routes/api.users.register.ts b/app/routes/api.users.register.ts index 94e0f4a8..ec0e1de2 100644 --- a/app/routes/api.users.register.ts +++ b/app/routes/api.users.register.ts @@ -167,6 +167,7 @@ export const action = async ({ request }: Route.ActionArgs) => { const email = data.email const password = data.password const language = data.language as 'de_DE' | 'en_US' + const newsletterOptIn = data.newsletterOptIn const registration = await registerUser( username, @@ -174,6 +175,7 @@ export const action = async ({ request }: Route.ActionArgs) => { password, language, true, + newsletterOptIn, ) if (!registration.ok) { diff --git a/app/routes/explore.register.tsx b/app/routes/explore.register.tsx index b12b7b12..4ef69a38 100644 --- a/app/routes/explore.register.tsx +++ b/app/routes/explore.register.tsx @@ -12,6 +12,7 @@ import { } from 'react-router' import invariant from 'tiny-invariant' import { type Route } from './+types/explore.register' +import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import Spinner from '~/components/spinner' @@ -39,7 +40,7 @@ export async function loader({ request }: Route.LoaderArgs) { export async function action({ context, request }: Route.ActionArgs) { const formData = await request.formData() - const { username, email, password, tosAccepted } = + const { username, email, password, tosAccepted, newsletterOptIn } = Object.fromEntries(formData) const redirectTo = safeRedirect(formData.get('redirectTo'), '/explore') @@ -186,6 +187,7 @@ export async function action({ context, request }: Route.ActionArgs) { password, language, tosAccepted === 'on', + newsletterOptIn === 'on', ) if (!result.ok) { @@ -364,11 +366,10 @@ export default function RegisterDialog() { )}
- @@ -385,6 +386,16 @@ export default function RegisterDialog() { {t('agree_tos_suffix')}
+
+ + +
+ +
+
+ + handleNewsletterChange(checked === true) + } + disabled={autosave.isSaving} + /> + +
+ + +
) From 754745b5eb662290d5fb6e96381b05ad59187bf8 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 16 Jun 2026 15:27:43 +0200 Subject: [PATCH 08/26] feat: translations --- public/locales/de/register.json | 1 + public/locales/de/settings.json | 3 +++ public/locales/en/register.json | 1 + public/locales/en/settings.json | 3 +++ 4 files changed, 8 insertions(+) diff --git a/public/locales/de/register.json b/public/locales/de/register.json index 3bcfa1f0..4310fa7d 100644 --- a/public/locales/de/register.json +++ b/public/locales/de/register.json @@ -26,6 +26,7 @@ "terms_of_service": "Nutzungsbedingungen", "agree_tos_suffix": "zu.", "tos_must_accept": "Den Nutzungsbedingungen muss zugestimmt werden.", + "newsletter_opt_in": "Ich möchte den openSenseMap-Newsletter erhalten.", "account_created": "Konto erstellt", "email_delivery_failed_description": "Dein Konto wurde erfolgreich erstellt, aber wir konnten keine Bestätigungs-E-Mail senden. Bitte logge dich ein und sende sie erneut über deine Kontoeinstellungen. Wenn dieses Problem weiterhin besteht, kannst du einen Fehlerbericht einreichen.", diff --git a/public/locales/de/settings.json b/public/locales/de/settings.json index 4cf13322..43c742dc 100644 --- a/public/locales/de/settings.json +++ b/public/locales/de/settings.json @@ -6,6 +6,7 @@ "delete_account": "Konto löschen", "profile_updated": "Profil aktualisiert", "profile_updated_description": "Dein Profil wurde erfolgreich aktualisiert.", + "preferences_saved": "Einstellungen gespeichert.", "something_went_wrong": "Etwas ist schief gelaufen.", "something_went_wrong_description": "Bitte versuche es später erneut.", "profile_settings": "Profileinstellungen", @@ -45,6 +46,8 @@ "email_already_confirmed": "E-Mail ist bereits bestätigt.", "language": "Sprache", "select_language": "Wähle die Sprache aus", + "receive_newsletter_messages": "Newsletter-Nachrichten erhalten", + "newsletter_sync_failed": "Newsletter-Einstellung gespeichert, aber die Synchronisierung mit dem E-Mail-Anbieter ist fehlgeschlagen.Bitte kontaktiere uns unter support@opensensemap.org", "confirm_password": "Bestätige dein Passwort", "enter_current_password": "Gib dein aktuelles Passwort ein", "try_again": "Versuche es nochmal.", diff --git a/public/locales/en/register.json b/public/locales/en/register.json index bd231bc5..cf9396b2 100644 --- a/public/locales/en/register.json +++ b/public/locales/en/register.json @@ -26,6 +26,7 @@ "terms_of_service": "Terms of Service", "agree_tos_suffix": ".", "tos_must_accept": "The terms of service must be accepted.", + "newsletter_opt_in": "I would like to receive the openSenseMap newsletter.", "account_created": "Account Created", "email_delivery_failed_description": "Your account was successfully created, but we couldn't send the confirmation email. Please log in and resend it from your account settings. If this issue persists, consider opening a bug report.", diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 3cf1fb34..838157d6 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -6,6 +6,7 @@ "delete_account": "Delete Account", "profile_updated": "Profile updated", "profile_updated_description": "Your profile has been updated successfully.", + "preferences_saved": "Preferences saved.", "something_went_wrong": "Something went wrong.", "something_went_wrong_description": "Please try again later.", "profile_settings": "Profile Settings", @@ -47,6 +48,8 @@ "email_already_confirmed": "Email is already confirmed", "language": "Language", "select_language": "Select language", + "receive_newsletter_messages": "Receive newsletter messages", + "newsletter_sync_failed": "Newsletter preference saved, but syncing with the email provider failed. Please contact us via support@opensensemap.org.", "confirm_password": "Confirm password", "enter_current_password": "Enter your current password", "cancel": "Cancel", From 0f777f0cf041b30993fbcbe5dda8e8c9c5e1c2e4 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 16 Jun 2026 15:44:12 +0200 Subject: [PATCH 09/26] fix(test): include newsletter flag, add test for checkbox values --- tests/lib/request-parsing.spec.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/lib/request-parsing.spec.ts b/tests/lib/request-parsing.spec.ts index 5f1ba59f..b541c773 100644 --- a/tests/lib/request-parsing.spec.ts +++ b/tests/lib/request-parsing.spec.ts @@ -65,6 +65,7 @@ describe('parseUserRegistrationData', () => { password: 'password123', language: 'de_DE', tosAccepted: true, + newsletterOptIn: true, } const request = new Request('http://localhost', { method: 'POST', @@ -79,6 +80,7 @@ describe('parseUserRegistrationData', () => { password: 'password123', language: 'de_DE', tosAccepted: true, + newsletterOptIn: true, }) }) @@ -102,6 +104,32 @@ describe('parseUserRegistrationData', () => { password: 'password123', language: 'en_US', tosAccepted: true, + newsletterOptIn: false, + }) + }) + + it('should parse form checkbox values for registration consent fields', async () => { + const formData = new URLSearchParams({ + name: 'john_doe', + email: 'john@example.com', + password: 'password123', + tosAccepted: 'on', + newsletterOptIn: 'on', + }) + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: formData.toString(), + }) + + const result = await parseUserRegistrationData(request) + expect(result).toEqual({ + name: 'john_doe', + email: 'john@example.com', + password: 'password123', + language: 'en_US', + tosAccepted: true, + newsletterOptIn: true, }) }) }) From 9b8391e1084531cde143b271991a8f14b33f21b6 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 18 Jun 2026 09:29:58 +0200 Subject: [PATCH 10/26] fix: mailgun api base url example eu --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 5ce4fd26..9cbea049 100644 --- a/.env.example +++ b/.env.example @@ -38,7 +38,7 @@ SMTP_USERNAME = "ignored" SMTP_PASSWORD = "ignored" MAILGUN_API_KEY="" -MAILGUN_API_BASE_URL="https://api.mailgun.net" +MAILGUN_API_BASE_URL="https://api.eu.mailgun.net" MAILGUN_NEWSLETTER_LIST="newsletter-beta@mg.opensensemap.org" MAILGUN_WEBHOOK_SIGNING_KEY="" From ee24b3360d69ce61bcf60319cd8cd08d1e00dea5 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 10:37:25 +0200 Subject: [PATCH 11/26] feat: add optional newsletter opt in to register user schema --- app/routes/api.users.register.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/routes/api.users.register.ts b/app/routes/api.users.register.ts index ec0e1de2..24423a08 100644 --- a/app/routes/api.users.register.ts +++ b/app/routes/api.users.register.ts @@ -56,6 +56,12 @@ const RegisterUserRequestSchema = z 'Preferred user language. Used for the website and emails. Defaults to `en_US`.', example: 'en_US', }), + + newsletterOptIn: z.boolean().optional().default(false).meta({ + description: + 'Whether to request a newsletter subscription. If true, a double opt-in confirmation email is sent before the user is subscribed.', + example: true, + }), }) .meta({ id: 'RegisterUserRequest', From b9c96391768c1b6605d6a4377c9fb10ceb51b662 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 10:39:26 +0200 Subject: [PATCH 12/26] feat: add newsletterOptIn field to user schema for api docs --- app/lib/openapi/schemas/user.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/lib/openapi/schemas/user.ts b/app/lib/openapi/schemas/user.ts index 8592efa8..300bd431 100644 --- a/app/lib/openapi/schemas/user.ts +++ b/app/lib/openapi/schemas/user.ts @@ -42,6 +42,12 @@ export const UserSchema = z example: true, }), + newsletterOptIn: z.boolean().meta({ + description: + 'Whether the user has confirmed the newsletter double opt-in and is actively subscribed.', + example: false, + }), + createdAt: z.iso.datetime().meta({ description: 'Account creation timestamp', example: '2024-01-15T10:30:00.000Z', From 79f8da29cea24d339d938695e5a6d1918089b09b Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 10:40:17 +0200 Subject: [PATCH 13/26] feat: add newsletterOptIn field for updating own user --- app/routes/api.users.me.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/routes/api.users.me.ts b/app/routes/api.users.me.ts index b59503e4..a3cdf74e 100644 --- a/app/routes/api.users.me.ts +++ b/app/routes/api.users.me.ts @@ -72,6 +72,11 @@ const UpdateCurrentUserRequestSchema = z format: 'password', }), newPassword: NewPasswordSchema.optional(), + newsletterOptIn: z.boolean().optional().meta({ + description: + 'Whether to request or disable newsletter messages. Enabling sends a double opt-in confirmation email before the subscription becomes active.', + example: true, + }), }) .superRefine((data, ctx) => { if (data.email && data.newPassword) { From 7bcc806de382f2fb285335648ba41c9af608e557 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 10:41:24 +0200 Subject: [PATCH 14/26] feat: add newsletter confirmation action token --- app/db/schema/action-token.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/db/schema/action-token.ts b/app/db/schema/action-token.ts index 2e6af09b..7441dbb9 100644 --- a/app/db/schema/action-token.ts +++ b/app/db/schema/action-token.ts @@ -22,7 +22,12 @@ export const actionToken = pgTable( .references(() => user.id, { onDelete: 'cascade' }), purpose: text('purpose') - .$type<'email_confirmation' | 'password_reset' | 'tos_acceptance'>() + .$type< + | 'email_confirmation' + | 'password_reset' + | 'tos_acceptance' + | 'newsletter_confirmation' + >() .notNull(), tokenHash: text('token_hash').notNull().unique(), From 38be43a62dc36a3df344d104ae1bd7a98123214e Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 10:42:42 +0200 Subject: [PATCH 15/26] feat: methods to manage newsletter confirmation tokens --- app/db/models/token.server.ts | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/app/db/models/token.server.ts b/app/db/models/token.server.ts index 2831ef66..79207814 100644 --- a/app/db/models/token.server.ts +++ b/app/db/models/token.server.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes } from 'node:crypto' +import { and, eq, gt } from 'drizzle-orm' import { actionToken } from '~/db/schema' import { drizzleClient } from '~/db.server' @@ -11,6 +12,7 @@ export function hashActionToken(token: string) { } const EMAIL_CONFIRMATION_TTL_MS = 24 * 3600000 //* ONE_HOUR_MILLIS +const NEWSLETTER_CONFIRMATION_TTL_MS = 7 * 24 * 3600000 // 7 days export async function issueEmailConfirmationToken(userId: string) { const rawToken = generateRawActionToken() @@ -34,3 +36,50 @@ export async function issueEmailConfirmationToken(userId: string) { return rawToken } + +export async function issueNewsletterConfirmationToken(userId: string) { + const rawToken = generateRawActionToken() + const tokenHash = hashActionToken(rawToken) + + await drizzleClient + .insert(actionToken) + .values({ + userId, + purpose: 'newsletter_confirmation', + tokenHash, + expiresAt: new Date(Date.now() + NEWSLETTER_CONFIRMATION_TTL_MS), + }) + .onConflictDoUpdate({ + target: [actionToken.userId, actionToken.purpose], + set: { + tokenHash, + expiresAt: new Date(Date.now() + NEWSLETTER_CONFIRMATION_TTL_MS), + }, + }) + + return rawToken +} + +export async function hasPendingNewsletterConfirmationToken(userId: string) { + const token = await drizzleClient.query.actionToken.findFirst({ + where: (t) => + and( + eq(t.userId, userId), + eq(t.purpose, 'newsletter_confirmation'), + gt(t.expiresAt, new Date()), + ), + }) + + return Boolean(token) +} + +export async function revokeNewsletterConfirmationToken(userId: string) { + await drizzleClient + .delete(actionToken) + .where( + and( + eq(actionToken.userId, userId), + eq(actionToken.purpose, 'newsletter_confirmation'), + ), + ) +} From ed4adaf44ff931a89a7c9d3432fa0b29bc6612ad Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 10:44:54 +0200 Subject: [PATCH 16/26] fix: wait for double-opt in confirmation to sync with mailgun --- app/services/user-service.server.ts | 38 +++++++++++++++++------------ 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/app/services/user-service.server.ts b/app/services/user-service.server.ts index a447948d..fe332a33 100644 --- a/app/services/user-service.server.ts +++ b/app/services/user-service.server.ts @@ -41,7 +41,11 @@ import PasswordResetEmail, { subject as PasswordResetEmailSubject, } from '~/emails/password-reset' import { subject as ResendEmailConfirmationSubject } from '~/emails/resend-email-confirmation' -import { syncNewsletterSubscriptionWithMailgun } from '~/services/newsletter-service.server' +import { + disableNewsletterForUser, + hasPendingNewsletterConfirmation, + requestNewsletterConfirmation, +} from '~/services/newsletter-service.server' const ONE_HOUR_MILLIS: number = 60 * 60 * 1000 @@ -160,7 +164,7 @@ export const registerUser = async ( language, password, tos.id, - newsletterOptIn, + false, ) if (newUsers.length === 0) { @@ -179,11 +183,11 @@ export const registerUser = async ( const newUser = newUsers[0] const lng = (newUser.language?.split('_')[0] as 'de' | 'en') ?? 'en' - if (newUser.newsletterOptIn) { + if (newsletterOptIn) { try { - await syncNewsletterSubscriptionWithMailgun(newUser) + await requestNewsletterConfirmation(newUser) } catch (err) { - console.error('Failed to sync newsletter subscription:', err) + console.error('Failed to send newsletter confirmation email:', err) } } @@ -369,16 +373,20 @@ export const updateUserDetails = async ( hasChanges = true } - if ( - typeof newsletterOptIn === 'boolean' && - user.newsletterOptIn !== newsletterOptIn - ) { - const updatedUser = await updateUserPreferencesById(user.id, { - newsletterOptIn, - }) - await syncNewsletterSubscriptionWithMailgun(updatedUser) - messages.push('Newsletter preference changed.') - hasChanges = true + if (typeof newsletterOptIn === 'boolean') { + const newsletterOptInPending = await hasPendingNewsletterConfirmation(user.id) + const currentNewsletterRequested = + user.newsletterOptIn || newsletterOptInPending + + if (currentNewsletterRequested !== newsletterOptIn) { + if (newsletterOptIn) { + await requestNewsletterConfirmation(user) + } else { + await disableNewsletterForUser(user) + } + messages.push('Newsletter preference changed.') + hasChanges = true + } } if (hasChanges) { From 0e5260f773578492ea718c7875a8e5288a0e3e54 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 10:46:15 +0200 Subject: [PATCH 17/26] feat: newsletter confirmation email --- app/emails/newsletter-confirmation.tsx | 154 +++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 app/emails/newsletter-confirmation.tsx diff --git a/app/emails/newsletter-confirmation.tsx b/app/emails/newsletter-confirmation.tsx new file mode 100644 index 00000000..07cf6efc --- /dev/null +++ b/app/emails/newsletter-confirmation.tsx @@ -0,0 +1,154 @@ +import { createIntl } from '@formatjs/intl' +import { + Html, + Head, + Body, + Link, + Preview, + Container, + Text, + Heading, +} from 'react-email' + +const messages = { + en: { + preview: 'Confirm your openSenseMap newsletter subscription', + heading: 'Confirm newsletter subscription', + hello: 'Hi', + description: + 'Please confirm that you want to receive the openSenseMap newsletter by clicking the link below.', + link: 'Confirm newsletter subscription', + hint: 'If you are unable to click the link, you can also open this address with your web browser:', + valid: 'This link is valid for 7 days.', + ignore: "If you didn't request this, please ignore this email.", + support: 'If you have any questions, feel free to write us an email to:', + salutation: 'Best wishes your openSenseMap Team', + }, + de: { + preview: 'Bestätige dein openSenseMap Newsletter-Abonnement', + heading: 'Newsletter-Abonnement bestätigen', + hello: 'Hallo', + description: + 'Bitte bestätige, dass du den openSenseMap-Newsletter erhalten möchtest, indem du auf den folgenden Link klickst.', + link: 'Newsletter-Abonnement bestätigen', + hint: 'Wenn sich der Link nicht anklicken lässt, kannst du auch diese Adresse kopieren und mit deinem Browser öffnen:', + valid: 'Dieser Link ist 7 Tage gültig.', + ignore: + 'Falls du den Newsletter nicht angefordert hast, ignoriere diese E-Mail.', + support: 'Wenn du Fragen hast, schreib uns eine Mail an:', + salutation: 'Viele Grüße, dein openSenseMap Team', + }, +} + +interface NewsletterConfirmationEmailProps { + user: { + name: string + email: string + } + token: string + language: 'de' | 'en' +} + +const baseUrl = process.env.OSEM_URL + ? `https://${process.env.OSEM_URL}` + : 'https://opensensemap.org' + +export const NewsletterConfirmationEmail = ({ + user = { name: 'Max Mustermann', email: 'max.mustermann@example.com' }, + token = '1234-5678-9012', + language = 'en', +}: NewsletterConfirmationEmailProps) => { + const intl = createIntl({ + locale: language, + messages: messages[language], + }) + const confirmationUrl = `${baseUrl}/account/confirm-newsletter?token=${token}` + + return ( + + + {intl.formatMessage({ id: 'preview' })} + + + {intl.formatMessage({ id: 'heading' })} + + {intl.formatMessage({ id: 'hello' })} {user.name}, + + {intl.formatMessage({ id: 'description' })} + + {intl.formatMessage({ id: 'link' })} + + {intl.formatMessage({ id: 'hint' })} + {confirmationUrl} + + {intl.formatMessage({ id: 'valid' })} + + {intl.formatMessage({ id: 'ignore' })} + + {intl.formatMessage({ id: 'support' })} {} + + support@opensensemap.org + + + {intl.formatMessage({ id: 'salutation' })} + + + + ) +} + +export default NewsletterConfirmationEmail + +export const subject = { + de: 'Bestätige dein openSenseMap Newsletter-Abonnement', + en: 'Confirm your openSenseMap newsletter subscription', +} + +const main = { + backgroundColor: '#ffffff', +} + +const container = { + paddingLeft: '12px', + paddingRight: '12px', + margin: '0 auto', +} + +const h1 = { + color: '#333', + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + fontSize: '24px', + fontWeight: 'bold', + margin: '40px 0', + padding: '0', +} + +const text = { + color: '#333', + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + fontSize: '14px', + margin: '24px 0', +} + +const code = { + display: 'inline-block', + padding: '16px 4.5%', + width: '90.5%', + backgroundColor: '#f4f4f4', + borderRadius: '5px', + border: '1px solid #eee', + color: '#333', +} From d2608632fa07ed96be0592e36adc30a45aa940b0 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 10:47:51 +0200 Subject: [PATCH 18/26] feat: component to handle newsletter confirmation and redirect --- app/routes/account.confirm-newsletter.tsx | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 app/routes/account.confirm-newsletter.tsx diff --git a/app/routes/account.confirm-newsletter.tsx b/app/routes/account.confirm-newsletter.tsx new file mode 100644 index 00000000..09024eb6 --- /dev/null +++ b/app/routes/account.confirm-newsletter.tsx @@ -0,0 +1,38 @@ +import { redirect } from 'react-router' +import { type Route } from './+types/account.confirm-newsletter' +import { + authSessionStorage, + getUserSession, +} from '~/services/session-service.server' +import { confirmNewsletterSubscription } from '~/services/newsletter-service.server' + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url) + const token = url.searchParams.get('token')?.trim() + + if (!token) { + return redirect('/settings/preferences?newsletterConfirm=missing_params') + } + + const result = await confirmNewsletterSubscription(token) + + if (result === 'success') { + const session = await getUserSession(request) + + return redirect('/settings/preferences?newsletterConfirm=ok', { + headers: { + 'Set-Cookie': await authSessionStorage.commitSession(session), + }, + }) + } + + if (result === 'expired') { + return redirect('/settings/preferences?newsletterConfirm=expired') + } + + return redirect('/settings/preferences?newsletterConfirm=invalid') +} + +export default function ConfirmNewsletterRoute() { + return null +} From 66df3ccc47f822b97d8a2e71efa9ee22c58d2aaf Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 10:49:39 +0200 Subject: [PATCH 19/26] feat: handle double opt-in via newsletter service --- app/services/newsletter-service.server.ts | 119 +++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/app/services/newsletter-service.server.ts b/app/services/newsletter-service.server.ts index cf1a57b2..b30bc702 100644 --- a/app/services/newsletter-service.server.ts +++ b/app/services/newsletter-service.server.ts @@ -1,9 +1,21 @@ import crypto from 'node:crypto' +import { and, eq, gt, sql } from 'drizzle-orm' import { getUserByEmail, updateUserPreferencesById, } from '~/db/models/user.server' -import { type User } from '~/db/schema' +import { + hashActionToken, + hasPendingNewsletterConfirmationToken, + issueNewsletterConfirmationToken, + revokeNewsletterConfirmationToken, +} from '~/db/models/token.server' +import { actionToken, user, type User } from '~/db/schema' +import { drizzleClient } from '~/db.server' +import NewsletterConfirmationEmail, { + subject as NewsletterConfirmationEmailSubject, +} from '~/emails/newsletter-confirmation' +import { sendMail } from '~/lib/mail.server' type MailgunWebhookPayload = { signature?: { @@ -86,11 +98,116 @@ export async function syncNewsletterSubscriptionWithMailgun(userToSync: User) { ) } +/** Sends the newsletter double-opt-in confirmation email for a user. */ +export async function requestNewsletterConfirmation(userToConfirm: User) { + if (userToConfirm.newsletterOptIn) return 'already_confirmed' as const + + const token = await issueNewsletterConfirmationToken(userToConfirm.id) + const lng = (userToConfirm.language?.split('_')[0] as 'de' | 'en') ?? 'en' + + try { + await sendMail({ + recipientAddress: userToConfirm.email, + recipientName: userToConfirm.name, + subject: NewsletterConfirmationEmailSubject[lng], + body: NewsletterConfirmationEmail({ + user: { + name: userToConfirm.name, + email: userToConfirm.email, + }, + token, + language: lng, + }), + }) + } catch (err) { + await revokeNewsletterConfirmationToken(userToConfirm.id) + throw err + } + + return 'confirmation_sent' as const +} + +/** Returns whether a user has an unexpired newsletter confirmation pending. */ +export async function hasPendingNewsletterConfirmation(userId: User['id']) { + return hasPendingNewsletterConfirmationToken(userId) +} + +/** Cancels pending confirmation or disables an active newsletter subscription. */ +export async function disableNewsletterForUser(userToDisable: User) { + await revokeNewsletterConfirmationToken(userToDisable.id) + + if (!userToDisable.newsletterOptIn) return userToDisable + + const updatedUser = await updateUserPreferencesById(userToDisable.id, { + newsletterOptIn: false, + }) + await syncNewsletterSubscriptionWithMailgun(updatedUser) + + return updatedUser +} + +/** Confirms a pending newsletter opt-in token and subscribes the user in Mailgun. */ +export async function confirmNewsletterSubscription( + rawToken: string, +): Promise<'forbidden' | 'expired' | 'success'> { + const now = new Date() + const tokenHash = hashActionToken(rawToken) + + const token = await drizzleClient.query.actionToken.findFirst({ + where: (t) => + and( + eq(t.purpose, 'newsletter_confirmation'), + eq(t.tokenHash, tokenHash), + ), + }) + + if (!token) return 'forbidden' + if (now.getTime() > token.expiresAt.getTime()) return 'expired' + + const currentUser = await drizzleClient.query.user.findFirst({ + where: (u) => eq(u.id, token.userId), + }) + + if (!currentUser) return 'forbidden' + + await syncNewsletterSubscriptionWithMailgun({ + ...currentUser, + newsletterOptIn: true, + }) + + return drizzleClient.transaction(async (tx) => { + await tx + .update(user) + .set({ + newsletterOptIn: true, + updatedAt: sql`NOW()`, + }) + .where(eq(user.id, currentUser.id)) + + const deleted = await tx + .delete(actionToken) + .where( + and( + eq(actionToken.id, token.id), + eq(actionToken.tokenHash, tokenHash), + gt(actionToken.expiresAt, now), + ), + ) + .returning({ id: actionToken.id }) + + if (deleted.length === 0) return 'forbidden' as const + + return 'success' as const + }) +} + /** Disables the local newsletter preference for a Mailgun recipient email. */ export async function disableNewsletterForEmail(email: string) { const existingUser = await getUserByEmail(email.toLowerCase()) if (!existingUser) return null + await revokeNewsletterConfirmationToken(existingUser.id) + return updateUserPreferencesById(existingUser.id, { newsletterOptIn: false }) } From cb6f7995f731442586fbfea7c0b9af13b5778993 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 10:51:04 +0200 Subject: [PATCH 20/26] feat: handle double opt-in via preferences --- app/routes/settings.preferences.tsx | 130 +++++++++++++++++++++------- 1 file changed, 98 insertions(+), 32 deletions(-) diff --git a/app/routes/settings.preferences.tsx b/app/routes/settings.preferences.tsx index 2afa55f1..4c9ad348 100644 --- a/app/routes/settings.preferences.tsx +++ b/app/routes/settings.preferences.tsx @@ -10,18 +10,25 @@ import { Checkbox } from '~/components/ui/checkbox' import { Label } from '~/components/ui/label' import { useToast } from '~/components/ui/use-toast' import { useAutosaveFetcher } from '~/hooks/use-autosave-fetcher' -import { getUserById, updateUserPreferencesById } from '~/db/models/user.server' -import { syncNewsletterSubscriptionWithMailgun } from '~/services/newsletter-service.server' +import { getUserById } from '~/db/models/user.server' +import { + disableNewsletterForUser, + hasPendingNewsletterConfirmation, + requestNewsletterConfirmation, +} from '~/services/newsletter-service.server' import { getUserId } from '~/services/session-service.server' type PreferencesValues = { - newsletterOptIn: boolean + newsletterRequested: boolean } type PreferencesActionData = { success: boolean newsletterSyncFailed: boolean + newsletterConfirmationFailed: boolean newsletterOptIn: boolean + newsletterOptInPending: boolean + newsletterRequested: boolean } export async function loader({ request }: Route.LoaderArgs) { @@ -31,7 +38,12 @@ export async function loader({ request }: Route.LoaderArgs) { const user = await getUserById(userId) if (!user) return redirect('/') - return { newsletterOptIn: user.newsletterOptIn } + const newsletterOptInPending = await hasPendingNewsletterConfirmation(user.id) + + return { + newsletterOptIn: user.newsletterOptIn, + newsletterOptInPending, + } } export async function action({ request }: Route.ActionArgs) { @@ -42,59 +54,99 @@ export async function action({ request }: Route.ActionArgs) { if (!user) return redirect('/') const formData = await request.formData() - const newsletterOptIn = formData.get('newsletterOptIn') === 'on' + const newsletterRequested = formData.get('newsletterOptIn') === 'on' + const newsletterOptInPending = await hasPendingNewsletterConfirmation(user.id) + const currentNewsletterRequested = + user.newsletterOptIn || newsletterOptInPending - if (newsletterOptIn === user.newsletterOptIn) { + if (newsletterRequested === currentNewsletterRequested) { return data({ success: true, newsletterSyncFailed: false, - newsletterOptIn, + newsletterConfirmationFailed: false, + newsletterOptIn: user.newsletterOptIn, + newsletterOptInPending, + newsletterRequested, }) } - const updatedUser = await updateUserPreferencesById(user.id, { - newsletterOptIn, - }) - try { - await syncNewsletterSubscriptionWithMailgun(updatedUser) + if (newsletterRequested) { + await requestNewsletterConfirmation(user) + return data({ + success: true, + newsletterSyncFailed: false, + newsletterConfirmationFailed: false, + newsletterOptIn: user.newsletterOptIn, + newsletterOptInPending: true, + newsletterRequested: true, + }) + } + + const updatedUser = await disableNewsletterForUser(user) return data({ success: true, newsletterSyncFailed: false, - newsletterOptIn, + newsletterConfirmationFailed: false, + newsletterOptIn: updatedUser.newsletterOptIn, + newsletterOptInPending: false, + newsletterRequested: false, }) } catch { return data({ success: false, - newsletterSyncFailed: true, - newsletterOptIn, + newsletterSyncFailed: !newsletterRequested, + newsletterConfirmationFailed: newsletterRequested, + newsletterOptIn: user.newsletterOptIn, + newsletterOptInPending, + newsletterRequested: currentNewsletterRequested, }) } } export default function PreferencesSettingsPage() { - const { newsletterOptIn: initialNewsletterOptIn } = - useLoaderData() + const { + newsletterOptIn: initialNewsletterOptIn, + newsletterOptInPending: initialNewsletterOptInPending, + } = useLoaderData() const { toast } = useToast() const { t } = useTranslation('settings') - const [newsletterOptIn, setNewsletterOptIn] = useState(initialNewsletterOptIn) + const [newsletterRequested, setNewsletterRequested] = useState( + initialNewsletterOptIn || initialNewsletterOptInPending, + ) + const [newsletterOptInPending, setNewsletterOptInPending] = useState( + initialNewsletterOptInPending, + ) const autosave = useAutosaveFetcher( { - values: { newsletterOptIn }, - lastSavedValues: { newsletterOptIn: initialNewsletterOptIn }, + values: { newsletterRequested }, + lastSavedValues: { + newsletterRequested: + initialNewsletterOptIn || initialNewsletterOptInPending, + }, enabled: false, getPayload: (values) => ({ - newsletterOptIn: values.newsletterOptIn ? 'on' : 'false', + newsletterOptIn: values.newsletterRequested ? 'on' : 'false', }), - isSuccess: (data) => data.success && !data.newsletterSyncFailed, + isSuccess: (data) => + data.success && + !data.newsletterSyncFailed && + !data.newsletterConfirmationFailed, getSavedValues: (data, submittedValues) => ({ - newsletterOptIn: - data.newsletterOptIn ?? submittedValues.newsletterOptIn, + newsletterRequested: + data.newsletterRequested ?? submittedValues.newsletterRequested, }), - onError: () => { + onSuccess: (data) => { + setNewsletterOptInPending(data.newsletterOptInPending) + }, + onError: (data) => { + setNewsletterRequested(data.newsletterRequested) + setNewsletterOptInPending(data.newsletterOptInPending) toast({ - title: t('newsletter_sync_failed'), + title: data.newsletterConfirmationFailed + ? t('newsletter_confirmation_failed') + : t('newsletter_sync_failed'), variant: 'destructive', }) }, @@ -102,14 +154,23 @@ export default function PreferencesSettingsPage() { ) useEffect(() => { - setNewsletterOptIn(initialNewsletterOptIn) - autosave.resetLastSaved({ newsletterOptIn: initialNewsletterOptIn }) - }, [initialNewsletterOptIn, autosave.resetLastSaved]) + const nextNewsletterRequested = + initialNewsletterOptIn || initialNewsletterOptInPending + setNewsletterRequested(nextNewsletterRequested) + setNewsletterOptInPending(initialNewsletterOptInPending) + autosave.resetLastSaved({ + newsletterRequested: nextNewsletterRequested, + }) + }, [ + initialNewsletterOptIn, + initialNewsletterOptInPending, + autosave.resetLastSaved, + ]) const handleNewsletterChange = useCallback( (checked: boolean) => { - const nextValues = { newsletterOptIn: checked } - setNewsletterOptIn(checked) + const nextValues = { newsletterRequested: checked } + setNewsletterRequested(checked) autosave.submit(nextValues) }, [autosave], @@ -147,7 +208,7 @@ export default function PreferencesSettingsPage() { id="newsletterOptIn" name="newsletterOptIn" value="on" - checked={newsletterOptIn} + checked={newsletterRequested} onCheckedChange={(checked) => handleNewsletterChange(checked === true) } @@ -155,6 +216,11 @@ export default function PreferencesSettingsPage() { /> From 0951ecc4bbbf2f21471631dcb5bde4038afbbee4 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 10:51:15 +0200 Subject: [PATCH 21/26] feat: translations --- public/locales/de/settings.json | 2 ++ public/locales/en/settings.json | 2 ++ 2 files changed, 4 insertions(+) diff --git a/public/locales/de/settings.json b/public/locales/de/settings.json index 43c742dc..971bad5e 100644 --- a/public/locales/de/settings.json +++ b/public/locales/de/settings.json @@ -47,6 +47,8 @@ "language": "Sprache", "select_language": "Wähle die Sprache aus", "receive_newsletter_messages": "Newsletter-Nachrichten erhalten", + "newsletter_confirmation_pending": "Bestätigung ausstehend", + "newsletter_confirmation_failed": "Die Newsletter-Bestätigungs-E-Mail konnte nicht gesendet werden. Bitte versuche es später erneut.", "newsletter_sync_failed": "Newsletter-Einstellung gespeichert, aber die Synchronisierung mit dem E-Mail-Anbieter ist fehlgeschlagen.Bitte kontaktiere uns unter support@opensensemap.org", "confirm_password": "Bestätige dein Passwort", "enter_current_password": "Gib dein aktuelles Passwort ein", diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 838157d6..410c06db 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -49,6 +49,8 @@ "language": "Language", "select_language": "Select language", "receive_newsletter_messages": "Receive newsletter messages", + "newsletter_confirmation_pending": "Confirmation pending", + "newsletter_confirmation_failed": "We couldn't send the newsletter confirmation email. Please try again later.", "newsletter_sync_failed": "Newsletter preference saved, but syncing with the email provider failed. Please contact us via support@opensensemap.org.", "confirm_password": "Confirm password", "enter_current_password": "Enter your current password", From 645a357692c020c23747d6b866404fa09390a47f Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 11:37:56 +0200 Subject: [PATCH 22/26] feat: add toast notification for toggling the newsletter subscriptions --- app/routes/settings.preferences.tsx | 5 +++++ public/locales/de/settings.json | 2 ++ public/locales/en/settings.json | 2 ++ 3 files changed, 9 insertions(+) diff --git a/app/routes/settings.preferences.tsx b/app/routes/settings.preferences.tsx index 4c9ad348..88489b6c 100644 --- a/app/routes/settings.preferences.tsx +++ b/app/routes/settings.preferences.tsx @@ -139,6 +139,11 @@ export default function PreferencesSettingsPage() { }), onSuccess: (data) => { setNewsletterOptInPending(data.newsletterOptInPending) + toast({ + title: data.newsletterRequested + ? t('newsletter_confirmation_email_sent') + : t('newsletter_disabled'), + }) }, onError: (data) => { setNewsletterRequested(data.newsletterRequested) diff --git a/public/locales/de/settings.json b/public/locales/de/settings.json index 971bad5e..952714e3 100644 --- a/public/locales/de/settings.json +++ b/public/locales/de/settings.json @@ -48,6 +48,8 @@ "select_language": "Wähle die Sprache aus", "receive_newsletter_messages": "Newsletter-Nachrichten erhalten", "newsletter_confirmation_pending": "Bestätigung ausstehend", + "newsletter_confirmation_email_sent": "Bitte prüfe dein Postfach, um dein Newsletter-Abonnement zu bestätigen.", + "newsletter_disabled": "Newsletter-Nachrichten deaktiviert.", "newsletter_confirmation_failed": "Die Newsletter-Bestätigungs-E-Mail konnte nicht gesendet werden. Bitte versuche es später erneut.", "newsletter_sync_failed": "Newsletter-Einstellung gespeichert, aber die Synchronisierung mit dem E-Mail-Anbieter ist fehlgeschlagen.Bitte kontaktiere uns unter support@opensensemap.org", "confirm_password": "Bestätige dein Passwort", diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 410c06db..dc37734a 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -50,6 +50,8 @@ "select_language": "Select language", "receive_newsletter_messages": "Receive newsletter messages", "newsletter_confirmation_pending": "Confirmation pending", + "newsletter_confirmation_email_sent": "Please check your inbox to confirm your newsletter subscription.", + "newsletter_disabled": "Newsletter messages disabled.", "newsletter_confirmation_failed": "We couldn't send the newsletter confirmation email. Please try again later.", "newsletter_sync_failed": "Newsletter preference saved, but syncing with the email provider failed. Please contact us via support@opensensemap.org.", "confirm_password": "Confirm password", From 077a05543f68531f5f031e6054b5449b0f7c6ad5 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 22 Jun 2026 11:47:02 +0200 Subject: [PATCH 23/26] fix: conflicts --- app/routes/settings.preferences.tsx | 506 +++++++++++++++------------- 1 file changed, 272 insertions(+), 234 deletions(-) diff --git a/app/routes/settings.preferences.tsx b/app/routes/settings.preferences.tsx index 8a9c7afd..968b9290 100644 --- a/app/routes/settings.preferences.tsx +++ b/app/routes/settings.preferences.tsx @@ -1,13 +1,13 @@ import { useCallback, useEffect, useState } from 'react' -import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { data, redirect, useLoaderData } from 'react-router' +import { useLoaderData } from 'react-router' import { type Route } from './+types/settings.preferences' import { AutosaveStatusText } from '~/components/autosave-status.text' import { LanguageSelect } from '~/components/language-select' import { ThemeSelect } from '~/components/theme-select' import { Button } from '~/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '~/components/ui/card' +import { Checkbox } from '~/components/ui/checkbox' import { Input, numberInputWithoutSteppers } from '~/components/ui/input' import { Label } from '~/components/ui/label' import { useToast } from '~/components/ui/use-toast' @@ -15,27 +15,43 @@ import { AUTOSAVE_DELAY_MS, useAutosaveFetcher, } from '~/hooks/use-autosave-fetcher' +import { getProfileByUserId, updateProfile } from '~/db/models/profile.server' +import { getUserById } from '~/db/models/user.server' import { isOptionalMapViewInputValid, MAP_ZOOM_LIMITS, parseOptionalMapViewportInput, } from '~/lib/location' -import { getProfileByUserId, updateProfile } from '~/db/models/profile.server' +import { + disableNewsletterForUser, + hasPendingNewsletterConfirmation, + requestNewsletterConfirmation, +} from '~/services/newsletter-service.server' import { requireUserId } from '~/services/session-service.server' const DEFAULT_HOME_ZOOM = String(MAP_ZOOM_LIMITS.default) -export async function loader({ request }: Route.LoaderArgs) { - const userId = await requireUserId(request) - const profile = await getProfileByUserId(userId) - if (!profile) { - throw new Error('User profile not found') - } +type NewsletterValues = { + newsletterRequested: boolean +} - return { profile } +type NewsletterActionData = { + intent: 'autosave-newsletter-preferences' + success: boolean + newsletterSyncFailed: boolean + newsletterConfirmationFailed: boolean + newsletterOptIn: boolean + newsletterOptInPending: boolean + newsletterRequested: boolean } -export type PreferencesActionData = +type MapPreferenceAutosaveValues = { + homeLatitude: string + homeLongitude: string + homeZoom: string +} + +type MapPreferencesActionData = | { intent: 'autosave-map-preferences' success: true @@ -51,10 +67,128 @@ export type PreferencesActionData = message: string } +export type PreferencesActionData = + | MapPreferencesActionData + | NewsletterActionData + +export async function loader({ request }: Route.LoaderArgs) { + const userId = await requireUserId(request) + const [profile, user] = await Promise.all([ + getProfileByUserId(userId), + getUserById(userId), + ]) + + if (!profile || !user) { + throw new Error('User preferences could not be loaded') + } + + const newsletterOptInPending = await hasPendingNewsletterConfirmation(user.id) + + return { + profile, + newsletterOptIn: user.newsletterOptIn, + newsletterOptInPending, + } +} + export async function action({ request, }: Route.ActionArgs): Promise { const userId = await requireUserId(request) + const formData = await request.formData() + const intent = String(formData.get('intent') ?? '') + + if (intent === 'autosave-newsletter-preferences') { + return handleNewsletterPreferencesAction(userId, formData) + } + + if (intent === 'autosave-map-preferences') { + return handleMapPreferencesAction(userId, formData) + } + + return { + intent: 'autosave-map-preferences', + success: false, + message: 'Invalid intent.', + } +} + +async function handleNewsletterPreferencesAction( + userId: string, + formData: FormData, +): Promise { + const user = await getUserById(userId) + + if (!user) { + return { + intent: 'autosave-newsletter-preferences', + success: false, + newsletterSyncFailed: false, + newsletterConfirmationFailed: true, + newsletterOptIn: false, + newsletterOptInPending: false, + newsletterRequested: false, + } + } + + const newsletterRequested = formData.get('newsletterOptIn') === 'on' + const newsletterOptInPending = await hasPendingNewsletterConfirmation(user.id) + const currentNewsletterRequested = + user.newsletterOptIn || newsletterOptInPending + + if (newsletterRequested === currentNewsletterRequested) { + return { + intent: 'autosave-newsletter-preferences', + success: true, + newsletterSyncFailed: false, + newsletterConfirmationFailed: false, + newsletterOptIn: user.newsletterOptIn, + newsletterOptInPending, + newsletterRequested, + } + } + + try { + if (newsletterRequested) { + await requestNewsletterConfirmation(user) + return { + intent: 'autosave-newsletter-preferences', + success: true, + newsletterSyncFailed: false, + newsletterConfirmationFailed: false, + newsletterOptIn: user.newsletterOptIn, + newsletterOptInPending: true, + newsletterRequested: true, + } + } + + const updatedUser = await disableNewsletterForUser(user) + return { + intent: 'autosave-newsletter-preferences', + success: true, + newsletterSyncFailed: false, + newsletterConfirmationFailed: false, + newsletterOptIn: updatedUser.newsletterOptIn, + newsletterOptInPending: false, + newsletterRequested: false, + } + } catch { + return { + intent: 'autosave-newsletter-preferences', + success: false, + newsletterSyncFailed: !newsletterRequested, + newsletterConfirmationFailed: newsletterRequested, + newsletterOptIn: user.newsletterOptIn, + newsletterOptInPending, + newsletterRequested: currentNewsletterRequested, + } + } +} + +async function handleMapPreferencesAction( + userId: string, + formData: FormData, +): Promise { const profile = await getProfileByUserId(userId) if (!profile) { @@ -65,22 +199,12 @@ export async function action({ } } - const formData = await request.formData() - const intent = String(formData.get('intent') ?? '') const mapViewInput = { latitude: String(formData.get('homeLatitude') ?? ''), longitude: String(formData.get('homeLongitude') ?? ''), zoom: String(formData.get('homeZoom') ?? ''), } - if (intent !== 'autosave-map-preferences') { - return { - intent: 'autosave-map-preferences', - success: false, - message: 'Invalid intent.', - } - } - const parsedMapView = parseOptionalMapViewportInput(mapViewInput) if (!parsedMapView.success) { @@ -118,12 +242,6 @@ export async function action({ } } -type MapPreferenceAutosaveValues = { - homeLatitude: string - homeLongitude: string - homeZoom: string -} - function hasCompleteHomeLocation( values: Pick, ) { @@ -143,123 +261,32 @@ function normalizeMapPreferenceValues( : DEFAULT_HOME_ZOOM, } } -import { Checkbox } from '~/components/ui/checkbox' -import { Label } from '~/components/ui/label' -import { useToast } from '~/components/ui/use-toast' -import { useAutosaveFetcher } from '~/hooks/use-autosave-fetcher' -import { getUserById } from '~/db/models/user.server' -import { - disableNewsletterForUser, - hasPendingNewsletterConfirmation, - requestNewsletterConfirmation, -} from '~/services/newsletter-service.server' -import { getUserId } from '~/services/session-service.server' - -type PreferencesValues = { - newsletterRequested: boolean -} - -type PreferencesActionData = { - success: boolean - newsletterSyncFailed: boolean - newsletterConfirmationFailed: boolean - newsletterOptIn: boolean - newsletterOptInPending: boolean - newsletterRequested: boolean -} - -export async function loader({ request }: Route.LoaderArgs) { - const userId = await getUserId(request) - if (!userId) return redirect('/') - - const user = await getUserById(userId) - if (!user) return redirect('/') - - const newsletterOptInPending = await hasPendingNewsletterConfirmation(user.id) - - return { - newsletterOptIn: user.newsletterOptIn, - newsletterOptInPending, - } -} - -export async function action({ request }: Route.ActionArgs) { - const userId = await getUserId(request) - if (!userId) return redirect('/') - - const user = await getUserById(userId) - if (!user) return redirect('/') - - const formData = await request.formData() - const newsletterRequested = formData.get('newsletterOptIn') === 'on' - const newsletterOptInPending = await hasPendingNewsletterConfirmation(user.id) - const currentNewsletterRequested = - user.newsletterOptIn || newsletterOptInPending - - if (newsletterRequested === currentNewsletterRequested) { - return data({ - success: true, - newsletterSyncFailed: false, - newsletterConfirmationFailed: false, - newsletterOptIn: user.newsletterOptIn, - newsletterOptInPending, - newsletterRequested, - }) - } - - try { - if (newsletterRequested) { - await requestNewsletterConfirmation(user) - return data({ - success: true, - newsletterSyncFailed: false, - newsletterConfirmationFailed: false, - newsletterOptIn: user.newsletterOptIn, - newsletterOptInPending: true, - newsletterRequested: true, - }) - } - - const updatedUser = await disableNewsletterForUser(user) - return data({ - success: true, - newsletterSyncFailed: false, - newsletterConfirmationFailed: false, - newsletterOptIn: updatedUser.newsletterOptIn, - newsletterOptInPending: false, - newsletterRequested: false, - }) - } catch { - return data({ - success: false, - newsletterSyncFailed: !newsletterRequested, - newsletterConfirmationFailed: newsletterRequested, - newsletterOptIn: user.newsletterOptIn, - newsletterOptInPending, - newsletterRequested: currentNewsletterRequested, - }) - } -} export default function PreferencesSettingsPage() { - const data = useLoaderData() const { + profile, newsletterOptIn: initialNewsletterOptIn, newsletterOptInPending: initialNewsletterOptInPending, } = useLoaderData() const { toast } = useToast() const { t } = useTranslation('settings') - const { toast } = useToast() const [homeLatitude, setHomeLatitude] = useState( - data.profile.homeLatitude?.toString() ?? '', + profile.homeLatitude?.toString() ?? '', ) const [homeLongitude, setHomeLongitude] = useState( - data.profile.homeLongitude?.toString() ?? '', + profile.homeLongitude?.toString() ?? '', ) const [homeZoom, setHomeZoom] = useState( - data.profile.homeZoom?.toString() ?? DEFAULT_HOME_ZOOM, + profile.homeZoom?.toString() ?? DEFAULT_HOME_ZOOM, + ) + const [newsletterRequested, setNewsletterRequested] = useState( + initialNewsletterOptIn || initialNewsletterOptInPending, ) + const [newsletterOptInPending, setNewsletterOptInPending] = useState( + initialNewsletterOptInPending, + ) + const autosaveValues = normalizeMapPreferenceValues({ homeLatitude, homeLongitude, @@ -267,7 +294,7 @@ export default function PreferencesSettingsPage() { }) const homeZoomEnabled = hasCompleteHomeLocation(autosaveValues) - const validateAutosave = useCallback( + const validateMapAutosave = useCallback( (values: MapPreferenceAutosaveValues) => { return isOptionalMapViewInputValid({ latitude: values.homeLatitude, @@ -278,7 +305,7 @@ export default function PreferencesSettingsPage() { [], ) - const getAutosavePayload = useCallback( + const getMapAutosavePayload = useCallback( (values: MapPreferenceAutosaveValues) => ({ intent: 'autosave-map-preferences', homeLatitude: values.homeLatitude.trim(), @@ -288,15 +315,18 @@ export default function PreferencesSettingsPage() { [], ) - const isAutosaveSuccess = useCallback((actionData: PreferencesActionData) => { - return ( - actionData.intent === 'autosave-map-preferences' && actionData.success - ) - }, []) + const isMapAutosaveSuccess = useCallback( + (actionData: PreferencesActionData) => { + return ( + actionData.intent === 'autosave-map-preferences' && actionData.success + ) + }, + [], + ) - const getSavedValues = useCallback( + const getSavedMapValues = useCallback( (actionData: PreferencesActionData): MapPreferenceAutosaveValues => { - if (!actionData.success) { + if (actionData.intent !== 'autosave-map-preferences' || !actionData.success) { return { homeLatitude, homeLongitude, @@ -315,9 +345,11 @@ export default function PreferencesSettingsPage() { [homeLatitude, homeLongitude, homeZoom], ) - const handleAutosaveError = useCallback( + const handleMapAutosaveError = useCallback( (actionData: PreferencesActionData) => { - if (actionData.success) return + if (actionData.intent !== 'autosave-map-preferences' || actionData.success) { + return + } toast({ title: t('something_went_wrong'), @@ -328,75 +360,138 @@ export default function PreferencesSettingsPage() { [toast, t], ) - const autosave = useAutosaveFetcher< + const mapAutosave = useAutosaveFetcher< MapPreferenceAutosaveValues, PreferencesActionData >({ values: autosaveValues, lastSavedValues: { - homeLatitude: data.profile.homeLatitude?.toString() ?? '', - homeLongitude: data.profile.homeLongitude?.toString() ?? '', - homeZoom: data.profile.homeZoom?.toString() ?? DEFAULT_HOME_ZOOM, + homeLatitude: profile.homeLatitude?.toString() ?? '', + homeLongitude: profile.homeLongitude?.toString() ?? '', + homeZoom: profile.homeZoom?.toString() ?? DEFAULT_HOME_ZOOM, }, debounceMs: AUTOSAVE_DELAY_MS, - validate: validateAutosave, - getPayload: getAutosavePayload, - isSuccess: isAutosaveSuccess, - getSavedValues, - onError: handleAutosaveError, + validate: validateMapAutosave, + getPayload: getMapAutosavePayload, + isSuccess: isMapAutosaveSuccess, + getSavedValues: getSavedMapValues, + onError: handleMapAutosaveError, + }) + + const newsletterAutosave = useAutosaveFetcher< + NewsletterValues, + PreferencesActionData + >({ + values: { newsletterRequested }, + lastSavedValues: { + newsletterRequested: + initialNewsletterOptIn || initialNewsletterOptInPending, + }, + enabled: false, + getPayload: (values) => ({ + intent: 'autosave-newsletter-preferences', + newsletterOptIn: values.newsletterRequested ? 'on' : 'false', + }), + isSuccess: (data) => + data.intent === 'autosave-newsletter-preferences' && + data.success && + !data.newsletterSyncFailed && + !data.newsletterConfirmationFailed, + getSavedValues: (data, submittedValues) => ({ + newsletterRequested: + data.intent === 'autosave-newsletter-preferences' + ? data.newsletterRequested + : submittedValues.newsletterRequested, + }), + onSuccess: (data) => { + if (data.intent !== 'autosave-newsletter-preferences') return + + setNewsletterOptInPending(data.newsletterOptInPending) + toast({ + title: data.newsletterRequested + ? t('newsletter_confirmation_email_sent') + : t('newsletter_disabled'), + }) + }, + onError: (data) => { + if (data.intent !== 'autosave-newsletter-preferences') return + + setNewsletterRequested(data.newsletterRequested) + setNewsletterOptInPending(data.newsletterOptInPending) + toast({ + title: data.newsletterConfirmationFailed + ? t('newsletter_confirmation_failed') + : t('newsletter_sync_failed'), + variant: 'destructive', + }) + }, }) useEffect(() => { const nextValues = { - homeLatitude: data.profile.homeLatitude?.toString() ?? '', - homeLongitude: data.profile.homeLongitude?.toString() ?? '', - homeZoom: data.profile.homeZoom?.toString() ?? DEFAULT_HOME_ZOOM, + homeLatitude: profile.homeLatitude?.toString() ?? '', + homeLongitude: profile.homeLongitude?.toString() ?? '', + homeZoom: profile.homeZoom?.toString() ?? DEFAULT_HOME_ZOOM, } setHomeLatitude(nextValues.homeLatitude) setHomeLongitude(nextValues.homeLongitude) setHomeZoom(nextValues.homeZoom) - autosave.resetLastSaved(nextValues) + mapAutosave.resetLastSaved(nextValues) }, [ - data.profile.homeLatitude, - data.profile.homeLongitude, - data.profile.homeZoom, - autosave.resetLastSaved, + profile.homeLatitude, + profile.homeLongitude, + profile.homeZoom, + mapAutosave.resetLastSaved, ]) - const submitAutosave = useCallback( + useEffect(() => { + const nextNewsletterRequested = + initialNewsletterOptIn || initialNewsletterOptInPending + setNewsletterRequested(nextNewsletterRequested) + setNewsletterOptInPending(initialNewsletterOptInPending) + newsletterAutosave.resetLastSaved({ + newsletterRequested: nextNewsletterRequested, + }) + }, [ + initialNewsletterOptIn, + initialNewsletterOptInPending, + newsletterAutosave.resetLastSaved, + ]) + + const submitMapAutosave = useCallback( (nextValues: MapPreferenceAutosaveValues) => { const normalizedValues = normalizeMapPreferenceValues(nextValues) - if (validateAutosave(normalizedValues)) { - autosave.submit(normalizedValues) + if (validateMapAutosave(normalizedValues)) { + mapAutosave.submit(normalizedValues) } }, - [autosave, validateAutosave], + [mapAutosave, validateMapAutosave], ) const handleHomeLatitudeChange = useCallback( (value: string) => { setHomeLatitude(value) - submitAutosave({ + submitMapAutosave({ homeLatitude: value, homeLongitude, homeZoom, }) }, - [homeLongitude, homeZoom, submitAutosave], + [homeLongitude, homeZoom, submitMapAutosave], ) const handleHomeLongitudeChange = useCallback( (value: string) => { setHomeLongitude(value) - submitAutosave({ + submitMapAutosave({ homeLatitude, homeLongitude: value, homeZoom, }) }, - [homeLatitude, homeZoom, submitAutosave], + [homeLatitude, homeZoom, submitMapAutosave], ) const handleHomeZoomChange = useCallback( @@ -405,13 +500,13 @@ export default function PreferencesSettingsPage() { if (!hasCompleteHomeLocation({ homeLatitude, homeLongitude })) return - submitAutosave({ + submitMapAutosave({ homeLatitude, homeLongitude, homeZoom: value, }) }, - [homeLatitude, homeLongitude, submitAutosave], + [homeLatitude, homeLongitude, submitMapAutosave], ) const clearHomeLocation = useCallback(() => { @@ -424,83 +519,26 @@ export default function PreferencesSettingsPage() { setHomeLatitude(nextValues.homeLatitude) setHomeLongitude(nextValues.homeLongitude) setHomeZoom(nextValues.homeZoom) - autosave.submit(nextValues) - }, [autosave]) - const [newsletterRequested, setNewsletterRequested] = useState( - initialNewsletterOptIn || initialNewsletterOptInPending, - ) - const [newsletterOptInPending, setNewsletterOptInPending] = useState( - initialNewsletterOptInPending, - ) - - const autosave = useAutosaveFetcher( - { - values: { newsletterRequested }, - lastSavedValues: { - newsletterRequested: - initialNewsletterOptIn || initialNewsletterOptInPending, - }, - enabled: false, - getPayload: (values) => ({ - newsletterOptIn: values.newsletterRequested ? 'on' : 'false', - }), - isSuccess: (data) => - data.success && - !data.newsletterSyncFailed && - !data.newsletterConfirmationFailed, - getSavedValues: (data, submittedValues) => ({ - newsletterRequested: - data.newsletterRequested ?? submittedValues.newsletterRequested, - }), - onSuccess: (data) => { - setNewsletterOptInPending(data.newsletterOptInPending) - toast({ - title: data.newsletterRequested - ? t('newsletter_confirmation_email_sent') - : t('newsletter_disabled'), - }) - }, - onError: (data) => { - setNewsletterRequested(data.newsletterRequested) - setNewsletterOptInPending(data.newsletterOptInPending) - toast({ - title: data.newsletterConfirmationFailed - ? t('newsletter_confirmation_failed') - : t('newsletter_sync_failed'), - variant: 'destructive', - }) - }, - }, - ) - - useEffect(() => { - const nextNewsletterRequested = - initialNewsletterOptIn || initialNewsletterOptInPending - setNewsletterRequested(nextNewsletterRequested) - setNewsletterOptInPending(initialNewsletterOptInPending) - autosave.resetLastSaved({ - newsletterRequested: nextNewsletterRequested, - }) - }, [ - initialNewsletterOptIn, - initialNewsletterOptInPending, - autosave.resetLastSaved, - ]) + mapAutosave.submit(nextValues) + }, [mapAutosave]) const handleNewsletterChange = useCallback( (checked: boolean) => { const nextValues = { newsletterRequested: checked } setNewsletterRequested(checked) - autosave.submit(nextValues) + newsletterAutosave.submit(nextValues) }, - [autosave], + [newsletterAutosave], ) return ( {t('preferences')} - + @@ -606,7 +644,7 @@ export default function PreferencesSettingsPage() { onCheckedChange={(checked) => handleNewsletterChange(checked === true) } - disabled={autosave.isSaving} + disabled={newsletterAutosave.isSaving} />