diff --git a/.env.example b/.env.example index df07d61a..9cbea049 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,11 @@ SMTP_SECURE = "false" SMTP_USERNAME = "ignored" SMTP_PASSWORD = "ignored" +MAILGUN_API_KEY="" +MAILGUN_API_BASE_URL="https://api.eu.mailgun.net" +MAILGUN_NEWSLETTER_LIST="newsletter-beta@mg.opensensemap.org" +MAILGUN_WEBHOOK_SIGNING_KEY="" + S3_ENDPOINT=https://images.staging.opensensemap.org S3_REGION="eu-north-1" S3_BUCKET="device-images" diff --git a/app/db/drizzle/0045_blushing_chameleon.sql b/app/db/drizzle/0045_blushing_chameleon.sql new file mode 100644 index 00000000..f3bb30fe --- /dev/null +++ b/app/db/drizzle/0045_blushing_chameleon.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/0045_snapshot.json b/app/db/drizzle/meta/0045_snapshot.json new file mode 100644 index 00000000..bd55664b --- /dev/null +++ b/app/db/drizzle/meta/0045_snapshot.json @@ -0,0 +1,1666 @@ +{ + "id": "ef9b819e-831a-4fac-8183-8eb681b40a71", + "prevId": "455bb6e0-7317-40b3-9d71-ef56f4443540", + "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 + }, + "home_latitude": { + "name": "home_latitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "home_longitude": { + "name": "home_longitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "home_zoom": { + "name": "home_zoom", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "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 3f4e4ab9..76c934ac 100644 --- a/app/db/drizzle/meta/_journal.json +++ b/app/db/drizzle/meta/_journal.json @@ -316,6 +316,13 @@ "when": 1781707403150, "tag": "0044_dusty_ultragirl", "breakpoints": true + }, + { + "idx": 45, + "version": "7", + "when": 1782121660841, + "tag": "0045_blushing_chameleon", + "breakpoints": true } ] } \ No newline at end of file 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'), + ), + ) +} 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/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(), 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( 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', +} 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', 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/account.confirm-newsletter.tsx b/app/routes/account.confirm-newsletter.tsx new file mode 100644 index 00000000..0247dae1 --- /dev/null +++ b/app/routes/account.confirm-newsletter.tsx @@ -0,0 +1,90 @@ +import { useTranslation } from 'react-i18next' +import { Link, data, useLoaderData } from 'react-router' +import { type Route } from './+types/account.confirm-newsletter' +import { Button } from '~/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '~/components/ui/card' +import { confirmNewsletterSubscription } from '~/services/newsletter-service.server' +import { getUserId } from '~/services/session-service.server' + +type NewsletterConfirmationStatus = 'success' | 'expired' | 'invalid' + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url) + const token = url.searchParams.get('token')?.trim() + const userId = await getUserId(request) + + if (!token) { + return data({ + status: 'invalid' as NewsletterConfirmationStatus, + isLoggedIn: Boolean(userId), + }) + } + + const result = await confirmNewsletterSubscription(token) + + return data({ + status: + result === 'forbidden' + ? ('invalid' as NewsletterConfirmationStatus) + : result, + isLoggedIn: Boolean(userId), + }) +} + +export default function ConfirmNewsletterRoute() { + const { status, isLoggedIn } = useLoaderData() + const { t } = useTranslation('settings') + + const content = { + success: { + title: t('newsletter_confirmed'), + description: t('newsletter_confirmed_description'), + }, + expired: { + title: t('newsletter_confirmation_link_expired'), + description: t('newsletter_confirmation_link_expired_description'), + }, + invalid: { + title: t('newsletter_confirmation_link_invalid'), + description: t('newsletter_confirmation_link_invalid_description'), + }, + }[status] + + return ( +
+ + + + {content.title} + + {content.description} + + + + + + + {!isLoggedIn && ( + + + + )} + + +
+ ) +} diff --git a/app/routes/api.mailgun.newsletter-webhook.ts b/app/routes/api.mailgun.newsletter-webhook.ts new file mode 100644 index 00000000..16edbf38 --- /dev/null +++ b/app/routes/api.mailgun.newsletter-webhook.ts @@ -0,0 +1,48 @@ +import { type Route } from './+types/api.mailgun.newsletter-webhook' +import { StandardResponse } from '~/lib/responses' +import { + disableNewsletterForEmail, + getNewsletterWebhookEmail, + isMailgunUnsubscribeEvent, + verifyMailgunWebhookSignature, +} from '~/services/newsletter-service.server' + +type MailgunWebhookPayload = Parameters< + typeof verifyMailgunWebhookSignature +>[0] + +export const action = async ({ request }: Route.ActionArgs) => { + if (request.method !== 'POST') { + return StandardResponse.methodNotAllowed('Method Not Allowed') + } + + let payload: unknown + try { + payload = await request.json() + } catch { + return StandardResponse.badRequest('Invalid JSON payload') + } + + if (typeof payload !== 'object' || payload === null) { + return StandardResponse.badRequest('Invalid JSON payload') + } + + const mailgunPayload = payload as MailgunWebhookPayload + + if (!verifyMailgunWebhookSignature(mailgunPayload)) { + return StandardResponse.forbidden('Invalid Mailgun webhook signature') + } + + if (!isMailgunUnsubscribeEvent(mailgunPayload)) { + return StandardResponse.ok({ message: 'Ignored event' }) + } + + const email = getNewsletterWebhookEmail(mailgunPayload) + if (!email) { + return StandardResponse.badRequest('Missing recipient email') + } + + await disableNewsletterForEmail(email) + + return StandardResponse.ok({ message: 'Newsletter opt-in disabled' }) +} 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) { diff --git a/app/routes/api.users.register.ts b/app/routes/api.users.register.ts index 94e0f4a8..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', @@ -167,6 +173,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 +181,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={newsletterAutosave.isSaving} + /> + +
+ + +
) diff --git a/app/services/newsletter-service.server.ts b/app/services/newsletter-service.server.ts new file mode 100644 index 00000000..b30bc702 --- /dev/null +++ b/app/services/newsletter-service.server.ts @@ -0,0 +1,256 @@ +import crypto from 'node:crypto' +import { and, eq, gt, sql } from 'drizzle-orm' +import { + getUserByEmail, + updateUserPreferencesById, +} from '~/db/models/user.server' +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?: { + timestamp?: string + token?: string + signature?: string + } + 'event-data'?: { + event?: string + recipient?: string + message?: { + headers?: { + to?: string + } + } + } +} + +const getMailgunConfig = () => { + const apiKey = process.env.MAILGUN_API_KEY + const listAddress = process.env.MAILGUN_NEWSLETTER_LIST + const baseUrl = + process.env.MAILGUN_API_BASE_URL?.replace(/\/$/, '') ?? + 'https://api.mailgun.net' + + if (!apiKey || !listAddress) { + throw new Error( + 'Mailgun newsletter config missing. Set MAILGUN_API_KEY and MAILGUN_NEWSLETTER_LIST.', + ) + } + + return { apiKey, listAddress, baseUrl } +} + +const getAuthorizationHeader = (apiKey: string) => + `Basic ${Buffer.from(`api:${apiKey}`).toString('base64')}` + +/** Sends a form-encoded request to the configured Mailgun API. */ +async function mailgunRequest( + path: string, + method: 'POST' | 'PUT', + body: URLSearchParams, +) { + const config = getMailgunConfig() + + const response = await fetch(`${config.baseUrl}${path}`, { + method, + headers: { + Authorization: getAuthorizationHeader(config.apiKey), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }) + + if (!response.ok) { + const message = await response.text() + throw new Error( + `Mailgun newsletter sync failed: ${response.status} ${message}`, + ) + } + + return 'synced' +} + +/** Upserts the user as a Mailgun list member with their current opt-in state. */ +export async function syncNewsletterSubscriptionWithMailgun(userToSync: User) { + const config = getMailgunConfig() + + const body = new URLSearchParams({ + address: userToSync.email, + name: userToSync.name, + subscribed: String(userToSync.newsletterOptIn), + upsert: 'yes', + }) + + return mailgunRequest( + `/v3/lists/${encodeURIComponent(config.listAddress)}/members`, + 'POST', + body, + ) +} + +/** 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 }) +} + +/** Verifies that a webhook payload was signed by Mailgun. */ +export function verifyMailgunWebhookSignature( + payload: MailgunWebhookPayload, +): boolean { + const signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY + const signature = payload.signature + + if ( + !signingKey || + !signature?.timestamp || + !signature.token || + !signature.signature + ) { + return false + } + + const digest = crypto + .createHmac('sha256', signingKey) + .update(`${signature.timestamp}${signature.token}`) + .digest('hex') + + const expectedSignature = Buffer.from(digest) + const receivedSignature = Buffer.from(signature.signature) + + return ( + expectedSignature.length === receivedSignature.length && + crypto.timingSafeEqual(expectedSignature, receivedSignature) + ) +} + +/** Extracts the affected recipient email from a Mailgun webhook payload. */ +export function getNewsletterWebhookEmail(payload: MailgunWebhookPayload) { + return ( + payload['event-data']?.recipient ?? + payload['event-data']?.message?.headers?.to ?? + null + ) +} + +/** Returns true when the Mailgun webhook event represents an unsubscribe. */ +export function isMailgunUnsubscribeEvent(payload: MailgunWebhookPayload) { + return payload['event-data']?.event === 'unsubscribed' +} diff --git a/app/services/user-service.server.ts b/app/services/user-service.server.ts index 11b83058..fe332a33 100644 --- a/app/services/user-service.server.ts +++ b/app/services/user-service.server.ts @@ -41,6 +41,11 @@ import PasswordResetEmail, { subject as PasswordResetEmailSubject, } from '~/emails/password-reset' import { subject as ResendEmailConfirmationSubject } from '~/emails/resend-email-confirmation' +import { + disableNewsletterForUser, + hasPendingNewsletterConfirmation, + requestNewsletterConfirmation, +} from '~/services/newsletter-service.server' const ONE_HOUR_MILLIS: number = 60 * 60 * 1000 @@ -79,6 +84,7 @@ export const registerUser = async ( password: string, language: 'de_DE' | 'en_US', tosAccepted: boolean, + newsletterOptIn = false, ): Promise => { const normalizedUsername = username.trim() const normalizedEmail = email.trim().toLowerCase() @@ -158,6 +164,7 @@ export const registerUser = async ( language, password, tos.id, + false, ) if (newUsers.length === 0) { @@ -176,6 +183,14 @@ export const registerUser = async ( const newUser = newUsers[0] const lng = (newUser.language?.split('_')[0] as 'de' | 'en') ?? 'en' + if (newsletterOptIn) { + try { + await requestNewsletterConfirmation(newUser) + } catch (err) { + console.error('Failed to send newsletter confirmation email:', err) + } + } + const token = await issueEmailConfirmationToken(newUser.id) let emailSent = true @@ -222,6 +237,7 @@ export const updateUserDetails = async ( name?: string currentPassword?: string newPassword?: string + newsletterOptIn?: boolean }, ): Promise<{ updated: boolean @@ -229,7 +245,14 @@ export const updateUserDetails = async ( messages: string[] updatedUser: User }> => { - const { email, language, name, currentPassword, newPassword } = details + const { + email, + language, + name, + currentPassword, + newPassword, + newsletterOptIn, + } = details const messages: string[] = [] if (email && newPassword) { @@ -350,6 +373,22 @@ export const updateUserDetails = async ( 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) { const updatedUser = await getUserById(user.id) return { 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 b6d32abd..456ceb67 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,18 @@ "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_confirmation_pending": "Bestätigung ausstehend", + "newsletter_confirmation_email_sent": "Bitte prüfe dein Postfach, um dein Newsletter-Abonnement zu bestätigen.", + "newsletter_confirmed": "Newsletter-Abonnement bestätigt", + "newsletter_confirmed_description": "Du erhältst jetzt den openSenseMap-Newsletter.", + "newsletter_confirmation_link_expired": "Newsletter-Bestätigungslink abgelaufen", + "newsletter_confirmation_link_expired_description": "Bitte fordere in deinen Einstellungen eine neue Bestätigungs-E-Mail an.", + "newsletter_confirmation_link_invalid": "Newsletter-Bestätigungslink ungültig", + "newsletter_confirmation_link_invalid_description": "Dieser Newsletter-Bestätigungslink ist ungültig oder wurde bereits verwendet.", + "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", "enter_current_password": "Gib dein aktuelles Passwort ein", "try_again": "Versuche es nochmal.", @@ -89,5 +102,8 @@ "confirm_email_change": "Änderung der E-Mail Adresse bestätigen", "confirm_email_change_description": "Bitte gib dein aktuelles Passwort ein.", "theme_description": "Entscheide selbst, wie openSenseMap für dich aussehen soll.", - "theme": "Erscheinungsbild" + "theme": "Erscheinungsbild", + "back_to_preferences": "Zurück zu den Einstellungen", + "go_to_explore": "Zur Karte", + "go_to_login": "Zur Anmeldung" } 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 d96f814e..6bf2c5be 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,18 @@ "email_already_confirmed": "Email is already confirmed", "language": "Language", "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_confirmed": "Newsletter subscription confirmed", + "newsletter_confirmed_description": "You are now subscribed to the openSenseMap newsletter.", + "newsletter_confirmation_link_expired": "Newsletter confirmation link expired", + "newsletter_confirmation_link_expired_description": "Please request a new confirmation email from your preferences.", + "newsletter_confirmation_link_invalid": "Newsletter confirmation link invalid", + "newsletter_confirmation_link_invalid_description": "This newsletter confirmation link is invalid or has already been used.", + "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", "enter_current_password": "Enter your current password", "cancel": "Cancel", @@ -92,5 +105,8 @@ "confirm_email_change": "Confirm email change", "confirm_email_change_description": "Please enter your current password.", "theme_description": "Choose how openSenseMap looks to you.", - "theme": "Theme" + "theme": "Theme", + "back_to_preferences": "Back to preferences", + "go_to_explore": "Explore", + "go_to_login": "Login" } 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, }) }) })