From 363cd240fa1f19303bfd39798c70f5100128fe70 Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 02:56:46 -0500 Subject: [PATCH 1/6] feat: add user settings table and migration --- drizzle/0007_closed_roland_deschain.sql | 19 + drizzle/meta/0007_snapshot.json | 758 ++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/schema.ts | 43 ++ 4 files changed, 827 insertions(+) create mode 100644 drizzle/0007_closed_roland_deschain.sql create mode 100644 drizzle/meta/0007_snapshot.json diff --git a/drizzle/0007_closed_roland_deschain.sql b/drizzle/0007_closed_roland_deschain.sql new file mode 100644 index 0000000..d1a3534 --- /dev/null +++ b/drizzle/0007_closed_roland_deschain.sql @@ -0,0 +1,19 @@ +CREATE TYPE "public"."theme" AS ENUM('dark', 'light');--> statement-breakpoint +CREATE TYPE "public"."time_format" AS ENUM('12hr', '24hr');--> statement-breakpoint +CREATE TABLE "user_settings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "theme" "theme" DEFAULT 'dark' NOT NULL, + "application_language" varchar(16) DEFAULT 'en' NOT NULL, + "time_format" time_format DEFAULT '24hr' NOT NULL, + "activity_status" boolean DEFAULT true NOT NULL, + "push_notifications" boolean DEFAULT true NOT NULL, + "match_alert" boolean DEFAULT true NOT NULL, + "profile_interaction_alert" boolean DEFAULT true NOT NULL, + "profile_view_alert" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "user_settings_user_id_idx" ON "user_settings" USING btree ("user_id"); \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..578dec8 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,758 @@ +{ + "id": "9de8daf1-f482-46d9-bd37-06a323a21596", + "prevId": "dd63857d-8b83-4444-958d-d41d096f8bbe", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.profile_target_languages": { + "name": "profile_target_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "proficiency_level": { + "name": "proficiency_level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "profile_target_languages_profile_id_idx": { + "name": "profile_target_languages_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_language_idx": { + "name": "profile_target_languages_language_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_profile_language_idx": { + "name": "profile_target_languages_profile_language_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_profile_position_idx": { + "name": "profile_target_languages_profile_position_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profile_target_languages_profile_id_profiles_id_fk": { + "name": "profile_target_languages_profile_id_profiles_id_fk", + "tableFrom": "profile_target_languages", + "tableTo": "profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profile_target_languages_position_check": { + "name": "profile_target_languages_position_check", + "value": "\"profile_target_languages\".\"position\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_anonymous_copy": { + "name": "allow_anonymous_copy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_timezone": { + "name": "display_timezone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_availability": { + "name": "display_availability", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "primary_language": { + "name": "primary_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "target_language": { + "name": "target_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "proficiency_level": { + "name": "proficiency_level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "availability": { + "name": "availability", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'flexible'" + }, + "availability_days": { + "name": "availability_days", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "availability_from": { + "name": "availability_from", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "availability_to": { + "name": "availability_to", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "availability_any_time": { + "name": "availability_any_time", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_bumped_at": { + "name": "last_bumped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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": { + "profiles_user_id_idx": { + "name": "profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_public_idx": { + "name": "profiles_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_primary_language_idx": { + "name": "profiles_primary_language_idx", + "columns": [ + { + "expression": "primary_language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_target_language_idx": { + "name": "profiles_target_language_idx", + "columns": [ + { + "expression": "target_language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_country_idx": { + "name": "profiles_country_idx", + "columns": [ + { + "expression": "country", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_last_bumped_at_idx": { + "name": "profiles_last_bumped_at_idx", + "columns": [ + { + "expression": "last_bumped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profiles_user_id_users_id_fk": { + "name": "profiles_user_id_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profiles_bio_length_check": { + "name": "profiles_bio_length_check", + "value": "char_length(\"profiles\".\"bio\") between 10 and 500" + }, + "profiles_availability_check": { + "name": "profiles_availability_check", + "value": "\"profiles\".\"availability\" in ('weeknights', 'weekends', 'weekday_mornings', 'flexible')" + }, + "profiles_availability_days_check": { + "name": "profiles_availability_days_check", + "value": "\"profiles\".\"availability_days\" is null or \"profiles\".\"availability_days\" in ('any', 'weekdays', 'weekends')" + }, + "profiles_availability_from_check": { + "name": "profiles_availability_from_check", + "value": "\"profiles\".\"availability_from\" is null or \"profiles\".\"availability_from\" ~ '^[0-2][0-9]:[0-5][0-9]$'" + }, + "profiles_availability_to_check": { + "name": "profiles_availability_to_check", + "value": "\"profiles\".\"availability_to\" is null or \"profiles\".\"availability_to\" ~ '^[0-2][0-9]:[0-5][0-9]$'" + }, + "profiles_tags_limit_check": { + "name": "profiles_tags_limit_check", + "value": "cardinality(\"profiles\".\"tags\") <= 8" + } + }, + "isRLSEnabled": false + }, + "public.saved_profiles": { + "name": "saved_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "saved_profiles_user_profile_idx": { + "name": "saved_profiles_user_profile_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_profiles_user_id_idx": { + "name": "saved_profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_profiles_profile_id_idx": { + "name": "saved_profiles_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_profiles_user_id_users_id_fk": { + "name": "saved_profiles_user_id_users_id_fk", + "tableFrom": "saved_profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_profiles_profile_id_profiles_id_fk": { + "name": "saved_profiles_profile_id_profiles_id_fk", + "tableFrom": "saved_profiles", + "tableTo": "profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "theme", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dark'" + }, + "application_language": { + "name": "application_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "time_format": { + "name": "time_format", + "type": "time_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'24hr'" + }, + "activity_status": { + "name": "activity_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_notifications": { + "name": "push_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "match_alert": { + "name": "match_alert", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "profile_interaction_alert": { + "name": "profile_interaction_alert", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "profile_view_alert": { + "name": "profile_view_alert", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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": { + "user_settings_user_id_idx": { + "name": "user_settings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": { + "users_discord_user_id_idx": { + "name": "users_discord_user_id_idx", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.proficiency_level": { + "name": "proficiency_level", + "schema": "public", + "values": ["beginner", "intermediate", "advanced", "native-level"] + }, + "public.theme": { + "name": "theme", + "schema": "public", + "values": ["dark", "light"] + }, + "public.time_format": { + "name": "time_format", + "schema": "public", + "values": ["12hr", "24hr"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e701710..17d917e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1782067199140, "tag": "0006_strong_post", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1782114038962, + "tag": "0007_closed_roland_deschain", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index b5b2f76..1697f97 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -20,6 +20,10 @@ export const proficiencyLevelEnum = pgEnum('proficiency_level', [ 'native-level', ]); +export const themeEnum = pgEnum('theme', ['dark', 'light']); + +export const timeFormatEnum = pgEnum('time_format', ['12hr', '24hr']); + export const users = pgTable( 'users', { @@ -160,9 +164,39 @@ export const savedProfiles = pgTable( ], ); +export const userSettings = pgTable( + 'user_settings', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + theme: themeEnum('theme').default('dark').notNull(), + applicationLanguage: varchar('application_language', { length: 16 }) + .default('en') + .notNull(), + timeFormat: timeFormatEnum('time_format').default('24hr').notNull(), + activityStatus: boolean('activity_status').default(true).notNull(), + pushNotifications: boolean('push_notifications').default(true).notNull(), + matchAlert: boolean('match_alert').default(true).notNull(), + profileInteractionAlert: boolean('profile_interaction_alert') + .default(true) + .notNull(), + profileViewAlert: boolean('profile_view_alert').default(false).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [uniqueIndex('user_settings_user_id_idx').on(table.userId)], +); + export const usersRelations = relations(users, ({ many, one }) => ({ profile: one(profiles), savedProfiles: many(savedProfiles), + settings: one(userSettings), })); export const profilesRelations = relations(profiles, ({ many, one }) => ({ @@ -195,6 +229,13 @@ export const profileTargetLanguagesRelations = relations( }), ); +export const userSettingsRelations = relations(userSettings, ({ one }) => ({ + user: one(users, { + fields: [userSettings.userId], + references: [users.id], + }), +})); + export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Profile = typeof profiles.$inferSelect; @@ -204,3 +245,5 @@ export type NewProfileTargetLanguage = typeof profileTargetLanguages.$inferInsert; export type SavedProfile = typeof savedProfiles.$inferSelect; export type NewSavedProfile = typeof savedProfiles.$inferInsert; +export type UserSettings = typeof userSettings.$inferSelect; +export type NewUserSettings = typeof userSettings.$inferInsert; From 036bc333d1136fbd2a2597021d652430fe53e3a2 Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 02:56:53 -0500 Subject: [PATCH 2/6] feat: add settings persistence helpers --- src/db/index.ts | 1 + src/db/settings.ts | 90 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/db/settings.ts diff --git a/src/db/index.ts b/src/db/index.ts index e049da6..71f4cbd 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -2,3 +2,4 @@ export * from './account'; export * from './profiles'; export * from './saved'; export * from './schema'; +export * from './settings'; diff --git a/src/db/settings.ts b/src/db/settings.ts new file mode 100644 index 0000000..7fa980a --- /dev/null +++ b/src/db/settings.ts @@ -0,0 +1,90 @@ +import 'server-only'; + +import { eq } from 'drizzle-orm'; +import { db } from './client'; +import { + type NewProfile, + type NewUserSettings, + profiles, + type UserSettings, + userSettings, + users, +} from './schema'; + +export type UserSettingsValues = Pick< + NewUserSettings, + | 'theme' + | 'applicationLanguage' + | 'timeFormat' + | 'activityStatus' + | 'pushNotifications' + | 'matchAlert' + | 'profileInteractionAlert' + | 'profileViewAlert' +>; + +export type ProfilePrivacyValues = Pick< + NewProfile, + 'isPublic' | 'allowAnonymousCopy' | 'displayTimezone' +>; + +export const getUserSettingsByUserId = async ( + userId: string, +): Promise => { + const [settings] = await db + .select() + .from(userSettings) + .where(eq(userSettings.userId, userId)) + .limit(1); + + return settings ?? null; +}; + +export const getUserSettingsByDiscordUserId = async ( + discordUserId: string, +): Promise => { + const [row] = await db + .select({ settings: userSettings }) + .from(userSettings) + .innerJoin(users, eq(userSettings.userId, users.id)) + .where(eq(users.discordUserId, discordUserId)) + .limit(1); + + return row?.settings ?? null; +}; + +export const upsertUserSettings = async ( + userId: string, + values: UserSettingsValues, +) => { + const [settings] = await db + .insert(userSettings) + .values({ ...values, userId }) + .onConflictDoUpdate({ + target: userSettings.userId, + set: { ...values, updatedAt: new Date() }, + }) + .returning(); + + return settings; +}; + +export const updateUserEmail = async (userId: string, email: string) => { + await db + .update(users) + .set({ email, updatedAt: new Date() }) + .where(eq(users.id, userId)); +}; + +export const updateProfilePrivacyForUser = async ( + userId: string, + privacy: ProfilePrivacyValues, +) => { + const updated = await db + .update(profiles) + .set({ ...privacy, updatedAt: new Date() }) + .where(eq(profiles.userId, userId)) + .returning({ id: profiles.id }); + + return updated.length > 0; +}; From d597fcf9aa2d0e221716c00586ee592c180537d5 Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 02:56:54 -0500 Subject: [PATCH 3/6] refactor: extract settings form schema to shared module --- src/features/Settings/schema.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/features/Settings/schema.ts diff --git a/src/features/Settings/schema.ts b/src/features/Settings/schema.ts new file mode 100644 index 0000000..397773d --- /dev/null +++ b/src/features/Settings/schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const settingsSchema = z.object({ + isPublic: z.boolean(), + allowAnonymousCopy: z.boolean(), + displayTimezone: z.boolean(), + activityStatus: z.boolean(), + pushNotifications: z.boolean(), + matchAlert: z.boolean(), + profileInteractionAlert: z.boolean(), + profileViewAlert: z.boolean(), + theme: z.enum(['dark', 'light']), + applicationLanguage: z.string().min(1), + timeFormat: z.enum(['12hr', '24hr']), + email: z + .string() + .email({ message: 'emailInvalid' }) + .min(1, { message: 'emailRequired' }), +}); + +export type SettingsFormValues = z.infer; From 605259d641d5d99107ab03b930e4fb58f5d5ed24 Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 02:57:00 -0500 Subject: [PATCH 4/6] feat: add settings save endpoint --- src/app/api/settings/route.ts | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/app/api/settings/route.ts diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts new file mode 100644 index 0000000..256e678 --- /dev/null +++ b/src/app/api/settings/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { + updateProfilePrivacyForUser, + updateUserEmail, + upsertDiscordUser, + upsertUserSettings, +} from '@/db'; +import { settingsSchema } from '@/features/Settings/schema'; +import { getCurrentUser } from '@/lib/auth'; + +export const POST = async (request: Request) => { + const currentUser = await getCurrentUser(); + + if (!currentUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: unknown; + + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const payload = settingsSchema.safeParse(body); + + if (!payload.success) { + return NextResponse.json( + { error: 'Invalid settings', issues: payload.error.issues }, + { status: 400 }, + ); + } + + const values = payload.data; + const user = await upsertDiscordUser(currentUser); + + await updateUserEmail(user.id, values.email); + await updateProfilePrivacyForUser(user.id, { + isPublic: values.isPublic, + allowAnonymousCopy: values.allowAnonymousCopy, + displayTimezone: values.displayTimezone, + }); + await upsertUserSettings(user.id, { + theme: values.theme, + applicationLanguage: values.applicationLanguage, + timeFormat: values.timeFormat, + activityStatus: values.activityStatus, + pushNotifications: values.pushNotifications, + matchAlert: values.matchAlert, + profileInteractionAlert: values.profileInteractionAlert, + profileViewAlert: values.profileViewAlert, + }); + + return NextResponse.json({ saved: true }); +}; From 582d841f258538c2a2b56ab2fc2f0583e0b85e6b Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 02:57:02 -0500 Subject: [PATCH 5/6] feat: load and persist user settings on settings page --- .../[lang]/settings/SettingsRouteClient.tsx | 42 +++++++++++++++---- src/app/[lang]/settings/page.tsx | 28 +++++++++++-- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/app/[lang]/settings/SettingsRouteClient.tsx b/src/app/[lang]/settings/SettingsRouteClient.tsx index a912576..7889246 100644 --- a/src/app/[lang]/settings/SettingsRouteClient.tsx +++ b/src/app/[lang]/settings/SettingsRouteClient.tsx @@ -12,11 +12,34 @@ type SettingsRouteClientProps = { SettingsFormValues, 'allowAnonymousCopy' | 'displayTimezone' | 'isPublic' >; + initialSettings?: Pick< + SettingsFormValues, + | 'activityStatus' + | 'applicationLanguage' + | 'matchAlert' + | 'profileInteractionAlert' + | 'profileViewAlert' + | 'pushNotifications' + | 'theme' + | 'timeFormat' + >; locale: string; userAvatarUrl?: string; userDisplayName: string; }; +const saveSettings = async (data: SettingsFormValues) => { + const response = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error('Settings save failed'); + } +}; + const downloadAccountData = async () => { const response = await fetch('/api/account/export'); @@ -38,6 +61,7 @@ const downloadAccountData = async () => { export const SettingsRouteClient = ({ defaultEmail, initialPrivacySettings, + initialSettings, locale, userAvatarUrl, userDisplayName, @@ -47,14 +71,14 @@ export const SettingsRouteClient = ({ isPublic: initialPrivacySettings?.isPublic ?? true, allowAnonymousCopy: initialPrivacySettings?.allowAnonymousCopy ?? true, displayTimezone: initialPrivacySettings?.displayTimezone ?? true, - activityStatus: true, - pushNotifications: true, - matchAlert: true, - profileInteractionAlert: true, - profileViewAlert: false, - theme: 'dark', - applicationLanguage: locale, - timeFormat: '24hr', + activityStatus: initialSettings?.activityStatus ?? true, + pushNotifications: initialSettings?.pushNotifications ?? true, + matchAlert: initialSettings?.matchAlert ?? true, + profileInteractionAlert: initialSettings?.profileInteractionAlert ?? true, + profileViewAlert: initialSettings?.profileViewAlert ?? false, + theme: initialSettings?.theme ?? 'dark', + applicationLanguage: initialSettings?.applicationLanguage ?? locale, + timeFormat: initialSettings?.timeFormat ?? '24hr', email: defaultEmail, }; @@ -78,7 +102,7 @@ export const SettingsRouteClient = ({ router.refresh(); }} onExportData={downloadAccountData} - onSubmit={() => undefined} + onSubmit={saveSettings} onUpdateDiscordConnection={() => window.location.assign(`/api/auth/discord?locale=${locale}`) } diff --git a/src/app/[lang]/settings/page.tsx b/src/app/[lang]/settings/page.tsx index d907d15..9c947ab 100644 --- a/src/app/[lang]/settings/page.tsx +++ b/src/app/[lang]/settings/page.tsx @@ -1,5 +1,9 @@ import { redirect } from 'next/navigation'; -import { getProfileByDiscordUserId } from '@/db'; +import { + getProfileByDiscordUserId, + getUserByDiscordId, + getUserSettingsByDiscordUserId, +} from '@/db'; import { getCurrentUser } from '@/lib/auth'; import { SettingsRouteClient } from './SettingsRouteClient'; @@ -15,12 +19,16 @@ export default async function SettingsRoute({ redirect(`/${lang}`); } - const profile = await getProfileByDiscordUserId(user.id); + const [account, profile, settings] = await Promise.all([ + getUserByDiscordId(user.id), + getProfileByDiscordUserId(user.id), + getUserSettingsByDiscordUserId(user.id), + ]); return (
Date: Mon, 22 Jun 2026 02:57:07 -0500 Subject: [PATCH 6/6] feat: show settings save error feedback --- src/features/Settings/SettingsPage.tsx | 57 ++++++++++++-------------- src/locales/en.json | 1 + src/locales/ja.json | 1 + 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx index 39e6517..5bd7a04 100644 --- a/src/features/Settings/SettingsPage.tsx +++ b/src/features/Settings/SettingsPage.tsx @@ -6,7 +6,6 @@ import type React from 'react'; import { useEffect, useMemo, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { FaDiscord } from 'react-icons/fa'; -import { z } from 'zod'; import { Avatar } from '@/components/Avatar'; import { Button } from '@/components/Button'; import { @@ -19,26 +18,9 @@ import { } from '@/components/Form'; import { languageOptions, type TimeFormat } from '@/constants/languages'; import { CompareTable } from './CompareTable'; +import { type SettingsFormValues, settingsSchema } from './schema'; -const settingsSchema = z.object({ - isPublic: z.boolean(), - allowAnonymousCopy: z.boolean(), - displayTimezone: z.boolean(), - activityStatus: z.boolean(), - pushNotifications: z.boolean(), - matchAlert: z.boolean(), - profileInteractionAlert: z.boolean(), - profileViewAlert: z.boolean(), - theme: z.enum(['dark', 'light']), - applicationLanguage: z.string().min(1), - timeFormat: z.enum(['12hr', '24hr']), - email: z - .string() - .email({ message: 'emailInvalid' }) - .min(1, { message: 'emailRequired' }), -}); - -export type SettingsFormValues = z.infer; +export type { SettingsFormValues }; export type SettingsPageProps = { defaultValues: SettingsFormValues; @@ -133,6 +115,7 @@ export const SettingsPage: React.FC = ({ const [deleteStatus, setDeleteStatus] = useState< 'idle' | 'confirming' | 'loading' | 'error' >('idle'); + const [saveStatus, setSaveStatus] = useState<'idle' | 'error'>('idle'); const [deleteConfirmation, setDeleteConfirmation] = useState(''); const initialValues: SettingsFormValues = useMemo( @@ -169,13 +152,20 @@ export const SettingsPage: React.FC = ({ }; const onSubmit = async (data: SettingsFormValues) => { - if (typeof window !== 'undefined') { - localStorage.setItem('polycord_timeFormat', data.timeFormat); - window.dispatchEvent(new Event('timeFormatChanged')); - } + setSaveStatus('idle'); + + try { + await onSubmitProp?.(data); + + if (typeof window !== 'undefined') { + localStorage.setItem('polycord_timeFormat', data.timeFormat); + window.dispatchEvent(new Event('timeFormatChanged')); + } - await onSubmitProp?.(data); - reset(data); + reset(data); + } catch { + setSaveStatus('error'); + } }; const onInvalid = (formErrors: typeof errors) => { @@ -188,6 +178,7 @@ export const SettingsPage: React.FC = ({ reset(); setExportStatus('idle'); setDeleteStatus('idle'); + setSaveStatus('idle'); setDeleteConfirmation(''); }; @@ -750,11 +741,15 @@ export const SettingsPage: React.FC = ({ )}
- - {isDirty ? t('unsavedChanges') : t('allChangesSaved')} - + {saveStatus === 'error' ? ( + {t('saveError')} + ) : ( + + {isDirty ? t('unsavedChanges') : t('allChangesSaved')} + + )}