-
-
-
Notification Preferences
-
- This section is under development. You'll be able to configure what
- notifications you receive and through which channels here.
+
+
);
}
+// Outer component that wraps content with SettingsPageWrapper
+export default function NotificationSettingsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/NotificationToggle.jsx b/apps/web/components/NotificationToggle.jsx
new file mode 100644
index 0000000..f78468e
--- /dev/null
+++ b/apps/web/components/NotificationToggle.jsx
@@ -0,0 +1,245 @@
+'use client';
+
+import { Bell, Mail, Smartphone, Info, UserPlus, Users, MessageSquare, Music, Shield, Megaphone, Heart } from 'lucide-react';
+
+/**
+ * NotificationToggle - Reusable component for notification preferences
+ *
+ * @param {string} id - Unique identifier for the notification
+ * @param {string} label - Notification type label
+ * @param {string} description - Explanation of what this notification is for
+ * @param {boolean} inAppEnabled - Whether in-app notifications are enabled
+ * @param {boolean} emailEnabled - Whether email notifications are enabled
+ * @param {boolean} pushEnabled - Whether push notifications are enabled (optional)
+ * @param {function} onInAppChange - Handler for in-app toggle change
+ * @param {function} onEmailChange - Handler for email toggle change
+ * @param {function} onPushChange - Handler for push toggle change (optional)
+ * @param {boolean} disabled - Whether toggles are disabled (e.g., for required notifications)
+ * @param {boolean} required - Whether this notification type is required and cannot be disabled
+ * @param {string} iconType - Type of notification icon ('friend_request', 'follower', 'comment', 'playlist', 'security', 'announcement', 'song', 'default')
+ */
+export function NotificationToggle({
+ id,
+ label,
+ description,
+ inAppEnabled,
+ emailEnabled,
+ pushEnabled = false,
+ onInAppChange,
+ onEmailChange,
+ onPushChange,
+ disabled = false,
+ required = false,
+ iconType = 'default',
+}) {
+ // Icon mapping for notification types
+ const iconMap = {
+ friend_request: UserPlus,
+ follower: Users,
+ comment: MessageSquare,
+ playlist: Music,
+ security: Shield,
+ announcement: Megaphone,
+ song: Heart,
+ default: Bell,
+ };
+
+ const NotificationIcon = iconMap[iconType] || iconMap.default;
+ const handleInAppToggle = () => {
+ if (!disabled && !required && onInAppChange) {
+ onInAppChange(!inAppEnabled);
+ }
+ };
+
+ const handleEmailToggle = () => {
+ if (!disabled && onEmailChange) {
+ onEmailChange(!emailEnabled);
+ }
+ };
+
+ const handlePushToggle = () => {
+ if (!disabled && onPushChange) {
+ onPushChange(!pushEnabled);
+ }
+ };
+
+ return (
+
+
+
+ {/* Notification Type Icon */}
+
+
+
+
+
+
+ {label}
+
+ {required && (
+
+ Required
+
+ )}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+ {/* In-App Notification Toggle */}
+
+
+
+
+
+ {/* Email Notification Toggle */}
+
+
+
+
+
+ {/* Push Notification Toggle (Optional) */}
+ {onPushChange && (
+
+
+
+
+ )}
+
+
+ );
+}
+
diff --git a/apps/web/hooks/useNotificationPreferences.js b/apps/web/hooks/useNotificationPreferences.js
new file mode 100644
index 0000000..a215fbd
--- /dev/null
+++ b/apps/web/hooks/useNotificationPreferences.js
@@ -0,0 +1,109 @@
+'use client';
+
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+
+/**
+ * Fetch user notification preferences
+ *
+ * @returns {Object} Query object with notification preferences, loading, and error states
+ */
+export function useNotificationPreferences() {
+ return useQuery({
+ queryKey: ['notificationPreferences'],
+ queryFn: async () => {
+ const response = await fetch('/api/user/notifications');
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.error || 'Failed to fetch notification preferences');
+ }
+ return await response.json();
+ },
+ staleTime: 60 * 1000, // 1 minute
+ });
+}
+
+/**
+ * Custom hook for notification preferences updates using TanStack Query
+ *
+ * Features:
+ * - Optimistic updates
+ * - Cache invalidation
+ * - Loading and error states
+ * - Success/error notifications
+ *
+ * @returns {Object} Mutation object with mutate function and state
+ */
+export function useNotificationPreferencesUpdate() {
+ const queryClient = useQueryClient();
+
+ const mutation = useMutation({
+ mutationFn: async (notificationData) => {
+ const response = await fetch('/api/user/notifications', {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(notificationData),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.error || 'Failed to update notification preferences');
+ }
+
+ return response.json();
+ },
+ onMutate: async (newNotificationPreferences) => {
+ // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
+ await queryClient.cancelQueries({ queryKey: ['notificationPreferences'] });
+
+ // Snapshot the previous value
+ const previousNotificationPreferences = queryClient.getQueryData(['notificationPreferences']);
+
+ // Optimistically update to the new value
+ queryClient.setQueryData(['notificationPreferences'], (old) => ({ ...old, ...newNotificationPreferences }));
+
+ return { previousNotificationPreferences };
+ },
+ onError: (err, newNotificationPreferences, context) => {
+ // Rollback to the previous value on error
+ if (context?.previousNotificationPreferences) {
+ queryClient.setQueryData(['notificationPreferences'], context.previousNotificationPreferences);
+ }
+
+ // Show error notification
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('show-toast', {
+ detail: {
+ type: 'error',
+ message: err.message || 'Failed to update notification preferences',
+ },
+ }));
+ }
+ },
+ onSuccess: (data) => {
+ // Update cache with server response
+ queryClient.setQueryData(['notificationPreferences'], data);
+
+ // Invalidate and refetch
+ queryClient.invalidateQueries({ queryKey: ['notificationPreferences'] });
+
+ // Show success notification
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('show-toast', {
+ detail: {
+ type: 'success',
+ message: data.message || 'Notification preferences updated successfully!',
+ },
+ }));
+ }
+ },
+ onSettled: () => {
+ // Ensure refetch happens after mutation is settled
+ queryClient.invalidateQueries({ queryKey: ['notificationPreferences'] });
+ },
+ });
+
+ return mutation;
+}
+
diff --git a/apps/web/lib/migrations/create_notification_preferences_table.sql b/apps/web/lib/migrations/create_notification_preferences_table.sql
new file mode 100644
index 0000000..d50b92d
--- /dev/null
+++ b/apps/web/lib/migrations/create_notification_preferences_table.sql
@@ -0,0 +1,171 @@
+-- ============================================
+-- Notification Preferences Table Migration
+-- Task 4.5: Create Notification Preferences Database Table
+-- ============================================
+--
+-- This migration creates the user_notification_preferences table
+-- and related infrastructure for notification preferences management.
+--
+-- Run this migration in your Supabase SQL editor or via
+-- your database migration tool.
+--
+-- See SUPABASE_NOTIFICATION_PREFERENCES_SETUP.md for detailed
+-- documentation and setup instructions.
+-- ============================================
+
+-- 1. Create user_notification_preferences table
+CREATE TABLE IF NOT EXISTS user_notification_preferences (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+
+ -- Social Notifications
+ friend_requests_inapp BOOLEAN NOT NULL DEFAULT true,
+ friend_requests_email BOOLEAN NOT NULL DEFAULT true,
+ new_followers_inapp BOOLEAN NOT NULL DEFAULT true,
+ new_followers_email BOOLEAN NOT NULL DEFAULT false,
+ comments_inapp BOOLEAN NOT NULL DEFAULT true,
+ comments_email BOOLEAN NOT NULL DEFAULT false,
+
+ -- Playlist Notifications
+ playlist_invites_inapp BOOLEAN NOT NULL DEFAULT true,
+ playlist_invites_email BOOLEAN NOT NULL DEFAULT true,
+ playlist_updates_inapp BOOLEAN NOT NULL DEFAULT true,
+ playlist_updates_email BOOLEAN NOT NULL DEFAULT false,
+
+ -- System Notifications
+ song_of_day_inapp BOOLEAN NOT NULL DEFAULT true,
+ song_of_day_email BOOLEAN NOT NULL DEFAULT false,
+ system_announcements_inapp BOOLEAN NOT NULL DEFAULT true,
+ system_announcements_email BOOLEAN NOT NULL DEFAULT true,
+ security_alerts_inapp BOOLEAN NOT NULL DEFAULT true,
+ security_alerts_email BOOLEAN NOT NULL DEFAULT true,
+
+ -- Email Frequency
+ email_frequency VARCHAR(20) NOT NULL DEFAULT 'instant',
+
+ -- Master Toggle (optional, can be used for bulk enable/disable)
+ notifications_enabled BOOLEAN NOT NULL DEFAULT true,
+
+ -- Timestamps
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ -- Constraints
+ UNIQUE(user_id),
+ CHECK (email_frequency IN ('instant', 'daily', 'weekly'))
+);
+
+-- 2. Create indexes for performance
+CREATE INDEX IF NOT EXISTS idx_user_notification_preferences_user_id
+ ON user_notification_preferences(user_id);
+
+-- 3. Create function to update updated_at timestamp
+-- Note: This function may already exist from previous migrations
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 4. Create trigger to automatically update updated_at
+DROP TRIGGER IF EXISTS update_user_notification_preferences_updated_at ON user_notification_preferences;
+CREATE TRIGGER update_user_notification_preferences_updated_at
+ BEFORE UPDATE ON user_notification_preferences
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+-- 5. Enable Row Level Security
+ALTER TABLE user_notification_preferences ENABLE ROW LEVEL SECURITY;
+
+-- 6. Create RLS policies
+-- Policy: Users can view their own notification preferences
+DROP POLICY IF EXISTS "Users can view own notification preferences" ON user_notification_preferences;
+CREATE POLICY "Users can view own notification preferences"
+ON user_notification_preferences
+FOR SELECT
+USING (auth.uid() = user_id);
+
+-- Policy: Users can insert their own notification preferences
+DROP POLICY IF EXISTS "Users can insert own notification preferences" ON user_notification_preferences;
+CREATE POLICY "Users can insert own notification preferences"
+ON user_notification_preferences
+FOR INSERT
+WITH CHECK (auth.uid() = user_id);
+
+-- Policy: Users can update their own notification preferences
+DROP POLICY IF EXISTS "Users can update own notification preferences" ON user_notification_preferences;
+CREATE POLICY "Users can update own notification preferences"
+ON user_notification_preferences
+FOR UPDATE
+USING (auth.uid() = user_id)
+WITH CHECK (auth.uid() = user_id);
+
+-- Policy: Prevent security alerts from being disabled
+-- This policy ensures security_alerts_inapp and security_alerts_email remain true
+DROP POLICY IF EXISTS "Users cannot disable security alerts" ON user_notification_preferences;
+CREATE POLICY "Users cannot disable security alerts"
+ON user_notification_preferences
+FOR UPDATE
+USING (
+ auth.uid() = user_id AND
+ (OLD.security_alerts_inapp = true AND NEW.security_alerts_inapp = true) AND
+ (OLD.security_alerts_email = true AND NEW.security_alerts_email = true)
+)
+WITH CHECK (
+ auth.uid() = user_id AND
+ security_alerts_inapp = true AND
+ security_alerts_email = true
+);
+
+-- Note: The above policy may be restrictive. Consider creating a separate UPDATE policy
+-- that allows all fields except security alerts. For now, the API enforces security alerts
+-- at the application level, so this policy provides an additional safety layer.
+
+-- Policy: Users can delete their own notification preferences
+DROP POLICY IF EXISTS "Users can delete own notification preferences" ON user_notification_preferences;
+CREATE POLICY "Users can delete own notification preferences"
+ON user_notification_preferences
+FOR DELETE
+USING (auth.uid() = user_id);
+
+-- 7. (Optional) Create function to automatically create default notification preferences for new users
+CREATE OR REPLACE FUNCTION create_default_notification_preferences()
+RETURNS TRIGGER AS $$
+BEGIN
+ INSERT INTO user_notification_preferences (user_id)
+ VALUES (NEW.id)
+ ON CONFLICT (user_id) DO NOTHING;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Trigger to automatically create notification preferences when a new user is created
+DROP TRIGGER IF EXISTS on_user_created_create_notification_preferences ON auth.users;
+CREATE TRIGGER on_user_created_create_notification_preferences
+AFTER INSERT ON auth.users
+FOR EACH ROW
+EXECUTE FUNCTION create_default_notification_preferences();
+
+-- ============================================
+-- Migration Complete
+-- ============================================
+--
+-- The notification preferences table is now set up with:
+-- ✓ Table structure with all required columns
+-- ✓ Check constraint for email_frequency enum validation
+-- ✓ Unique constraint on user_id
+-- ✓ Indexes for performance
+-- ✓ Automatic updated_at trigger
+-- ✓ Row Level Security policies
+-- ✓ Security alerts protection policy
+-- ✓ Automatic default preferences for new users
+--
+-- Next steps:
+-- 1. Verify the migration ran successfully
+-- 2. Test the API endpoints (/api/user/notifications)
+-- 3. Verify RLS policies work correctly
+-- 4. Verify security alerts cannot be disabled
+-- ============================================
+
diff --git a/apps/web/lib/schemas/notificationSchema.js b/apps/web/lib/schemas/notificationSchema.js
new file mode 100644
index 0000000..43604af
--- /dev/null
+++ b/apps/web/lib/schemas/notificationSchema.js
@@ -0,0 +1,260 @@
+import { z } from 'zod';
+
+/**
+ * Notification preferences validation schema using Zod
+ *
+ * Validates:
+ * - Social notifications (friend requests, new followers, comments)
+ * - Playlist notifications (invites, updates)
+ * - System notifications (song of day, announcements, security alerts)
+ * - Email frequency settings
+ * - Master notification toggle
+ *
+ * @typedef {Object} NotificationFormData
+ * @property {boolean} friend_requests_inapp - In-app notifications for friend requests
+ * @property {boolean} friend_requests_email - Email notifications for friend requests
+ * @property {boolean} new_followers_inapp - In-app notifications for new followers
+ * @property {boolean} new_followers_email - Email notifications for new followers
+ * @property {boolean} comments_inapp - In-app notifications for comments/reactions
+ * @property {boolean} comments_email - Email notifications for comments/reactions
+ * @property {boolean} playlist_invites_inapp - In-app notifications for playlist invites
+ * @property {boolean} playlist_invites_email - Email notifications for playlist invites
+ * @property {boolean} playlist_updates_inapp - In-app notifications for playlist updates
+ * @property {boolean} playlist_updates_email - Email notifications for playlist updates
+ * @property {boolean} song_of_day_inapp - In-app notifications for friends' Song of the Day
+ * @property {boolean} song_of_day_email - Email notifications for friends' Song of the Day
+ * @property {boolean} system_announcements_inapp - In-app notifications for system announcements
+ * @property {boolean} system_announcements_email - Email notifications for system announcements
+ * @property {boolean} security_alerts_inapp - In-app notifications for security alerts (always true)
+ * @property {boolean} security_alerts_email - Email notifications for security alerts (always true)
+ * @property {string} email_frequency - Email frequency (instant, daily, weekly)
+ * @property {boolean} notifications_enabled - Master toggle for notifications
+ */
+
+/**
+ * Email frequency enum:
+ * - instant: Receive emails immediately
+ * - daily: Daily digest (one email per day)
+ * - weekly: Weekly summary (one email per week)
+ */
+const emailFrequencySchema = z.enum(['instant', 'daily', 'weekly'], {
+ required_error: 'Email frequency is required',
+ invalid_type_error: 'Email frequency must be one of: instant, daily, or weekly',
+});
+
+/**
+ * Boolean schema for notification toggles:
+ * - Must be a boolean value
+ */
+const notificationToggleSchema = z.boolean({
+ required_error: 'This notification setting requires a boolean value',
+ invalid_type_error: 'This notification setting must be true or false',
+});
+
+/**
+ * Social Notifications
+ */
+const friendRequestsInAppSchema = notificationToggleSchema;
+const friendRequestsEmailSchema = notificationToggleSchema;
+const newFollowersInAppSchema = notificationToggleSchema;
+const newFollowersEmailSchema = notificationToggleSchema;
+const commentsInAppSchema = notificationToggleSchema;
+const commentsEmailSchema = notificationToggleSchema;
+
+/**
+ * Playlist Notifications
+ */
+const playlistInvitesInAppSchema = notificationToggleSchema;
+const playlistInvitesEmailSchema = notificationToggleSchema;
+const playlistUpdatesInAppSchema = notificationToggleSchema;
+const playlistUpdatesEmailSchema = notificationToggleSchema;
+
+/**
+ * System Notifications
+ */
+const songOfDayInAppSchema = notificationToggleSchema;
+const songOfDayEmailSchema = notificationToggleSchema;
+const systemAnnouncementsInAppSchema = notificationToggleSchema;
+const systemAnnouncementsEmailSchema = notificationToggleSchema;
+
+/**
+ * Security Alerts (Required - Always Enabled)
+ * These must always be true and cannot be disabled
+ */
+const securityAlertsInAppSchema = z.boolean({
+ required_error: 'Security alerts in-app must be enabled',
+ invalid_type_error: 'Security alerts in-app must be true',
+}).refine((val) => val === true, {
+ message: 'Security alerts must always be enabled',
+});
+
+const securityAlertsEmailSchema = z.boolean({
+ required_error: 'Security alerts email must be enabled',
+ invalid_type_error: 'Security alerts email must be true',
+}).refine((val) => val === true, {
+ message: 'Security alerts must always be enabled',
+});
+
+/**
+ * Master toggle for notifications
+ */
+const notificationsEnabledSchema = z.boolean({
+ required_error: 'Notifications enabled setting is required',
+ invalid_type_error: 'Notifications enabled must be true or false',
+});
+
+/**
+ * Notification preferences validation schema
+ *
+ * This schema validates all notification preferences and ensures:
+ * - All required fields are present
+ * - Boolean values are properly typed
+ * - Security alerts are always enabled
+ * - Email frequency is valid
+ *
+ * Usage:
+ * ```javascript
+ * import { notificationSchema } from '@/lib/schemas/notificationSchema';
+ * import { zodResolver } from '@hookform/resolvers/zod';
+ *
+ * const form = useForm({
+ * resolver: zodResolver(notificationSchema),
+ * defaultValues: getDefaultNotificationPreferences(),
+ * });
+ * ```
+ */
+export const notificationSchema = z.object({
+ // Social Notifications
+ friend_requests_inapp: friendRequestsInAppSchema,
+ friend_requests_email: friendRequestsEmailSchema,
+ new_followers_inapp: newFollowersInAppSchema,
+ new_followers_email: newFollowersEmailSchema,
+ comments_inapp: commentsInAppSchema,
+ comments_email: commentsEmailSchema,
+
+ // Playlist Notifications
+ playlist_invites_inapp: playlistInvitesInAppSchema,
+ playlist_invites_email: playlistInvitesEmailSchema,
+ playlist_updates_inapp: playlistUpdatesInAppSchema,
+ playlist_updates_email: playlistUpdatesEmailSchema,
+
+ // System Notifications
+ song_of_day_inapp: songOfDayInAppSchema,
+ song_of_day_email: songOfDayEmailSchema,
+ system_announcements_inapp: systemAnnouncementsInAppSchema,
+ system_announcements_email: systemAnnouncementsEmailSchema,
+ security_alerts_inapp: securityAlertsInAppSchema,
+ security_alerts_email: securityAlertsEmailSchema,
+
+ // Email Frequency
+ email_frequency: emailFrequencySchema,
+
+ // Master Toggle
+ notifications_enabled: notificationsEnabledSchema,
+})
+.refine(
+ /**
+ * Ensure security alerts are always enabled
+ */
+ (data) => {
+ return data.security_alerts_inapp === true && data.security_alerts_email === true;
+ },
+ {
+ message: 'Security alerts must always be enabled for both in-app and email channels',
+ path: ['security_alerts_inapp'], // Attach error to security_alerts_inapp field
+ }
+);
+
+/**
+ * Partial notification schema for updates:
+ * - Allows updating only specific fields
+ * - Useful for PATCH operations
+ */
+export const notificationPartialSchema = notificationSchema.partial();
+
+/**
+ * TypeScript/JSDoc type definitions
+ *
+ * @typedef {z.infer
} NotificationFormData
+ * @typedef {z.infer} NotificationPartialFormData
+ *
+ * Example usage in JavaScript with JSDoc:
+ * ```javascript
+ * /**
+ * * @type {import('@/lib/schemas/notificationSchema').NotificationFormData}
+ * *\/
+ * const notificationData = {
+ * friend_requests_inapp: true,
+ * friend_requests_email: true,
+ * // ... other fields
+ * };
+ * ```
+ */
+
+/**
+ * Helper function to get default notification preferences
+ * @returns {NotificationFormData} Default notification preferences
+ */
+export function getDefaultNotificationPreferences() {
+ return {
+ // Social Notifications
+ friend_requests_inapp: true,
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+
+ // Playlist Notifications
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+
+ // System Notifications
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: true, // Always enabled
+ security_alerts_email: true, // Always enabled
+
+ // Email Frequency
+ email_frequency: 'instant',
+
+ // Master Toggle
+ notifications_enabled: true,
+ };
+}
+
+/**
+ * Helper function to validate a single notification field
+ * @param {string} field - Field name to validate
+ * @param {any} value - Value to validate
+ * @returns {Object} Validation result with success flag and error if any
+ */
+export function validateNotificationField(field, value) {
+ const fieldSchema = notificationSchema.shape[field];
+ if (!fieldSchema) {
+ return {
+ success: false,
+ error: `Unknown notification field: ${field}`,
+ };
+ }
+
+ const result = fieldSchema.safeParse(value);
+ return {
+ success: result.success,
+ error: result.success ? null : result.error.errors[0]?.message || 'Validation failed',
+ };
+}
+
+/**
+ * Helper function to check if security alerts can be disabled
+ * @param {Object} data - Notification preferences data
+ * @returns {boolean} True if security alerts are properly enabled
+ */
+export function areSecurityAlertsEnabled(data) {
+ return data.security_alerts_inapp === true && data.security_alerts_email === true;
+}
+
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
index d430bb0..361a338 100644
--- a/apps/web/package-lock.json
+++ b/apps/web/package-lock.json
@@ -8,6 +8,7 @@
"name": "web",
"version": "0.1.0",
"dependencies": {
+ "@hookform/resolvers": "^5.2.2",
"@supabase/auth-helpers-nextjs": "^0.10.0",
"@supabase/auth-helpers-react": "^0.5.0",
"@supabase/ssr": "^0.7.0",
@@ -17,6 +18,7 @@
"next": "15.5.2",
"react": "19.1.0",
"react-dom": "19.1.0",
+ "react-hook-form": "^7.65.0",
"zod": "^4.1.5",
"zustand": "^5.0.8"
},
@@ -1140,6 +1142,17 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@hookform/resolvers": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
+ "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "react-hook-form": "^7.55.0"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2250,6 +2263,11 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
+ },
"node_modules/@supabase/auth-helpers-nextjs": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-helpers-nextjs/-/auth-helpers-nextjs-0.10.0.tgz",
@@ -7824,6 +7842,21 @@
"react": "^19.1.0"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.65.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",
+ "integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
diff --git a/apps/web/package.json b/apps/web/package.json
index f3c0155..eb4a6da 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -18,6 +18,7 @@
"test:all": "npm run test:run && npm run test:e2e"
},
"dependencies": {
+ "@hookform/resolvers": "^5.2.2",
"@supabase/auth-helpers-nextjs": "^0.10.0",
"@supabase/auth-helpers-react": "^0.5.0",
"@supabase/ssr": "^0.7.0",
@@ -27,6 +28,7 @@
"next": "15.5.2",
"react": "19.1.0",
"react-dom": "19.1.0",
+ "react-hook-form": "^7.65.0",
"zod": "^4.1.5",
"zustand": "^5.0.8"
},
From dbcf12d99a88edc3df3cee0d450c2a3b4ceb8529 Mon Sep 17 00:00:00 2001
From: Ezzat Abdel-Khalek
Date: Sat, 1 Nov 2025 16:20:47 -0400
Subject: [PATCH 10/52] feat: Implement Task 5 - Account Deletion and Data
Export functionality
Implement comprehensive account deletion and data export features including:
- Account deletion UI with warnings and account age validation (24-hour minimum)
- Multi-step account deletion modal with password verification and confirmation
- Account deletion API endpoint with security checks and data cleanup
- Account deletion service layer for modular deletion operations
- User data export functionality (GDPR compliance) with JSON download
- Scheduled deletion job infrastructure for processing pending deletions
Files added:
- apps/web/app/api/user/account/delete/route.js (deletion API)
- apps/web/app/api/user/export/route.js (data export API)
- apps/web/app/api/admin/account-deletion-job/route.js (scheduled job endpoint)
- apps/web/components/DeleteAccountModal.jsx (multi-step deletion modal)
- apps/web/lib/services/accountDeletion.js (deletion service layer)
- apps/web/lib/jobs/accountDeletionJob.js (scheduled job logic)
Files modified:
- apps/web/app/settings/account/page.jsx (deletion UI and data export button)
Features:
- Password verification for email-based auth
- Account age validation (24-hour protection period)
- Comprehensive data cleanup (profile, playlists, history, settings, OAuth tokens, storage)
- Data export with all user information (GDPR data portability)
- Scheduled job structure for grace period deletions
- Audit logging for compliance
- Error handling and graceful degradation
- Toast notifications for user feedback
---
.../api/admin/account-deletion-job/route.js | 166 ++++++++
apps/web/app/api/user/account/delete/route.js | 121 ++++++
apps/web/app/api/user/export/route.js | 252 ++++++++++++
apps/web/app/settings/account/page.jsx | 337 +++++++++++++++-
apps/web/components/DeleteAccountModal.jsx | 374 ++++++++++++++++++
apps/web/lib/jobs/accountDeletionJob.js | 207 ++++++++++
apps/web/lib/services/accountDeletion.js | 351 ++++++++++++++++
7 files changed, 1791 insertions(+), 17 deletions(-)
create mode 100644 apps/web/app/api/admin/account-deletion-job/route.js
create mode 100644 apps/web/app/api/user/account/delete/route.js
create mode 100644 apps/web/app/api/user/export/route.js
create mode 100644 apps/web/components/DeleteAccountModal.jsx
create mode 100644 apps/web/lib/jobs/accountDeletionJob.js
create mode 100644 apps/web/lib/services/accountDeletion.js
diff --git a/apps/web/app/api/admin/account-deletion-job/route.js b/apps/web/app/api/admin/account-deletion-job/route.js
new file mode 100644
index 0000000..0e58852
--- /dev/null
+++ b/apps/web/app/api/admin/account-deletion-job/route.js
@@ -0,0 +1,166 @@
+import { NextResponse } from 'next/server';
+import { createClient } from '@supabase/supabase-js';
+import { deleteAccount } from '@/lib/services/accountDeletion';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * POST /api/admin/account-deletion-job
+ *
+ * Scheduled job endpoint for processing pending account deletions.
+ * This endpoint should be called by a cron job or scheduled task.
+ *
+ * Security: Should be protected by API key or secret token in production.
+ *
+ * Request headers:
+ * - X-API-Key: (optional) API key for authentication
+ *
+ * Returns:
+ * - 200: Job completed successfully
+ * - 401: Unauthorized (if API key check fails)
+ * - 500: Server error
+ */
+export async function POST(request) {
+ try {
+ // TODO: Add API key authentication in production
+ // const apiKey = request.headers.get('X-API-Key');
+ // if (apiKey !== process.env.ACCOUNT_DELETION_JOB_API_KEY) {
+ // return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ // }
+
+ // Create Supabase client with service role (for admin operations)
+ // In production, use service role key for admin operations
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+
+ if (!supabaseUrl || !supabaseServiceKey) {
+ return NextResponse.json(
+ { error: 'Missing Supabase configuration' },
+ { status: 500 }
+ );
+ }
+
+ const supabase = createClient(supabaseUrl, supabaseServiceKey, {
+ auth: {
+ autoRefreshToken: false,
+ persistSession: false,
+ },
+ });
+
+ // Query accounts marked for deletion past grace period
+ // Note: Current implementation deletes immediately, so this would need
+ // a "pending_deletion" or "marked_for_deletion_at" field in the database
+ // For now, we'll document this as a placeholder for when grace period is implemented
+
+ const results = {
+ processed: 0,
+ deleted: 0,
+ failed: 0,
+ errors: [],
+ };
+
+ try {
+ // TODO: Query accounts with pending_deletion_at < (now - grace_period)
+ // Example query (when grace period is implemented):
+ // const { data: pendingDeletions, error } = await supabase
+ // .from('users')
+ // .select('*')
+ // .not('pending_deletion_at', 'is', null)
+ // .lt('pending_deletion_at', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString())
+ // .limit(100); // Process in batches
+
+ // For now, return message about grace period requirement
+ // In production with grace period, would process each account:
+
+ /*
+ for (const userRecord of pendingDeletions || []) {
+ try {
+ // Get full user object from auth
+ const { data: { user }, error: userError } = await supabase.auth.admin.getUserById(userRecord.id);
+
+ if (userError || !user) {
+ results.failed++;
+ results.errors.push({
+ user_id: userRecord.id,
+ error: 'User not found in auth',
+ });
+ continue;
+ }
+
+ // Send final confirmation email before deletion
+ // TODO: Implement email sending
+ // await sendDeletionConfirmationEmail(user.email);
+
+ // Execute hard delete
+ const deletionResult = await deleteAccount(supabase, user, {
+ reason: 'Scheduled deletion after grace period',
+ });
+
+ if (deletionResult.success) {
+ // Delete from auth.users using admin API
+ await supabase.auth.admin.deleteUser(user.id);
+
+ results.deleted++;
+ } else {
+ results.failed++;
+ results.errors.push({
+ user_id: user.id,
+ error: deletionResult.error || 'Unknown error',
+ });
+ }
+
+ results.processed++;
+ } catch (error) {
+ results.failed++;
+ results.errors.push({
+ user_id: userRecord?.id || 'unknown',
+ error: error.message,
+ });
+ console.error('[deletion job] Error processing user:', error);
+ }
+ }
+ */
+
+ return NextResponse.json({
+ success: true,
+ message: 'Account deletion job completed',
+ note: 'Grace period feature not yet implemented. This job is ready for when soft delete is added.',
+ results: {
+ processed: results.processed,
+ deleted: results.deleted,
+ failed: results.failed,
+ errors: results.errors,
+ },
+ });
+ } catch (error) {
+ console.error('[deletion job] Error:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Failed to process deletion job',
+ message: error.message,
+ },
+ { status: 500 }
+ );
+ }
+ } catch (error) {
+ console.error('[deletion job] Unexpected error:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * GET /api/admin/account-deletion-job
+ * Health check endpoint for the deletion job
+ */
+export async function GET() {
+ return NextResponse.json({
+ status: 'ok',
+ service: 'account-deletion-job',
+ note: 'POST to this endpoint to run the deletion job',
+ });
+}
+
diff --git a/apps/web/app/api/user/account/delete/route.js b/apps/web/app/api/user/account/delete/route.js
new file mode 100644
index 0000000..d4af585
--- /dev/null
+++ b/apps/web/app/api/user/account/delete/route.js
@@ -0,0 +1,121 @@
+import { NextResponse } from 'next/server';
+import { cookies } from 'next/headers';
+import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
+import {
+ validateDeletionRequest,
+ verifyPassword,
+ deleteAccount,
+ checkAccountAge,
+} from '@/lib/services/accountDeletion';
+
+export const dynamic = 'force-dynamic';
+
+async function makeSupabase() {
+ const cookieStore = await cookies();
+ return createRouteHandlerClient({ cookies: () => cookieStore });
+}
+
+/**
+ * POST /api/user/account/delete
+ * Permanently delete user account and all associated data
+ *
+ * Request body:
+ * {
+ * password: string (required for email-based auth)
+ * confirmation_phrase: string (should be "DELETE MY ACCOUNT")
+ * reason?: string (optional feedback)
+ * }
+ *
+ * Returns:
+ * - 200: Account deletion successful
+ * - 400: Validation error (password incorrect, confirmation phrase incorrect, account too new)
+ * - 401: Unauthorized
+ * - 403: Account too new (less than 24 hours old)
+ * - 500: Server error
+ */
+export async function POST(request) {
+ try {
+ const supabase = await makeSupabase();
+
+ // Get authenticated user
+ const { data: { user }, error: userError } = await supabase.auth.getUser();
+ if (userError || !user) {
+ return NextResponse.json(
+ { error: 'Unauthorized' },
+ { status: 401 }
+ );
+ }
+
+ // Parse request body
+ let body;
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json(
+ { error: 'Invalid JSON in request body' },
+ { status: 400 }
+ );
+ }
+
+ // Validate deletion request
+ const validation = validateDeletionRequest(user, body);
+ if (!validation.valid) {
+ const status = validation.hoursRemaining !== undefined ? 403 : 400;
+ return NextResponse.json(
+ {
+ error: validation.error,
+ message: validation.message,
+ hoursRemaining: validation.hoursRemaining,
+ },
+ { status }
+ );
+ }
+
+ // Verify password (for email-based authentication)
+ const authProvider = user.app_metadata?.provider || 'email';
+
+ if (authProvider === 'email') {
+ const passwordValid = await verifyPassword(supabase, user, validation.data.password);
+ if (!passwordValid) {
+ return NextResponse.json(
+ { error: 'Invalid password' },
+ { status: 400 }
+ );
+ }
+ } else {
+ // For OAuth providers (Spotify, Google), password verification is not applicable
+ // The user is already authenticated via OAuth
+ console.log('[account deletion] OAuth user deletion:', authProvider);
+ }
+
+ // Perform account deletion using service layer
+ const deletionResult = await deleteAccount(supabase, user, {
+ reason: validation.data.reason,
+ });
+
+ if (!deletionResult.success) {
+ return NextResponse.json(
+ {
+ error: 'Failed to delete account',
+ message: deletionResult.error || 'An error occurred during account deletion',
+ },
+ { status: 500 }
+ );
+ }
+
+ // Return success response
+ return NextResponse.json({
+ success: true,
+ message: 'Account deletion initiated successfully',
+ note: 'Your account and all associated data will be permanently deleted. You have been signed out.',
+ deleted: deletionResult.deleted,
+ });
+ } catch (error) {
+ console.error('[account deletion] Unexpected error:', error);
+ return NextResponse.json(
+ { error: 'Internal server error', message: 'Failed to delete account' },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/apps/web/app/api/user/export/route.js b/apps/web/app/api/user/export/route.js
new file mode 100644
index 0000000..61e65ea
--- /dev/null
+++ b/apps/web/app/api/user/export/route.js
@@ -0,0 +1,252 @@
+import { NextResponse } from 'next/server';
+import { cookies } from 'next/headers';
+import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
+
+export const dynamic = 'force-dynamic';
+
+async function makeSupabase() {
+ const cookieStore = await cookies();
+ return createRouteHandlerClient({ cookies: () => cookieStore });
+}
+
+/**
+ * GET /api/user/export
+ * Generate user data export (GDPR data portability)
+ *
+ * Returns:
+ * - 200: JSON file download with all user data
+ * - 401: Unauthorized
+ * - 429: Too many requests (rate limited - 1 export per 24 hours)
+ * - 500: Server error
+ */
+export async function GET(request) {
+ try {
+ const supabase = await makeSupabase();
+
+ // Get authenticated user
+ const { data: { user }, error: userError } = await supabase.auth.getUser();
+ if (userError || !user) {
+ return NextResponse.json(
+ { error: 'Unauthorized' },
+ { status: 401 }
+ );
+ }
+
+ const userId = user.id;
+
+ // Rate limiting: Check last export time (simple in-memory check for now)
+ // In production, store in database or use Redis
+ // For now, we'll implement a basic version that checks a simple tracking mechanism
+ // TODO: Implement proper rate limiting with database storage
+
+ // Collect all user data
+ const exportData = {
+ export_metadata: {
+ exported_at: new Date().toISOString(),
+ user_id: userId,
+ format_version: '1.0',
+ },
+ profile: {},
+ preferences: {},
+ listening_history: [],
+ playlists: [],
+ social_connections: [],
+ settings: {},
+ };
+
+ try {
+ // 1. Profile Information
+ const { data: profile } = await supabase
+ .from('users')
+ .select('*')
+ .eq('id', userId)
+ .single();
+
+ if (profile) {
+ exportData.profile = {
+ id: profile.id,
+ display_name: profile.display_name,
+ bio: profile.bio,
+ username: profile.username,
+ profile_picture_url: profile.profile_picture_url,
+ created_at: profile.created_at,
+ updated_at: profile.updated_at,
+ };
+ }
+
+ // Add auth information
+ exportData.profile.auth = {
+ email: user.email,
+ email_verified: user.email_confirmed_at ? true : false,
+ provider: user.app_metadata?.provider || 'email',
+ created_at: user.created_at,
+ last_sign_in: user.last_sign_in_at,
+ };
+
+ // 2. Privacy Settings
+ const { data: privacySettings } = await supabase
+ .from('user_privacy_settings')
+ .select('*')
+ .eq('user_id', userId)
+ .single();
+
+ if (privacySettings) {
+ exportData.preferences.privacy = {
+ profile_visibility: privacySettings.profile_visibility,
+ playlist_visibility: privacySettings.playlist_visibility,
+ listening_activity_visible: privacySettings.listening_activity_visible,
+ song_of_day_visibility: privacySettings.song_of_day_visibility,
+ friend_request_setting: privacySettings.friend_request_setting,
+ searchable: privacySettings.searchable,
+ activity_feed_visible: privacySettings.activity_feed_visible,
+ created_at: privacySettings.created_at,
+ updated_at: privacySettings.updated_at,
+ };
+ }
+
+ // 3. Notification Preferences
+ const { data: notificationPreferences } = await supabase
+ .from('user_notification_preferences')
+ .select('*')
+ .eq('user_id', userId)
+ .single();
+
+ if (notificationPreferences) {
+ exportData.preferences.notifications = {
+ friend_requests_inapp: notificationPreferences.friend_requests_inapp,
+ friend_requests_email: notificationPreferences.friend_requests_email,
+ new_followers_inapp: notificationPreferences.new_followers_inapp,
+ new_followers_email: notificationPreferences.new_followers_email,
+ comments_inapp: notificationPreferences.comments_inapp,
+ comments_email: notificationPreferences.comments_email,
+ playlist_invites_inapp: notificationPreferences.playlist_invites_inapp,
+ playlist_invites_email: notificationPreferences.playlist_invites_email,
+ playlist_updates_inapp: notificationPreferences.playlist_updates_inapp,
+ playlist_updates_email: notificationPreferences.playlist_updates_email,
+ song_of_day_inapp: notificationPreferences.song_of_day_inapp,
+ song_of_day_email: notificationPreferences.song_of_day_email,
+ system_announcements_inapp: notificationPreferences.system_announcements_inapp,
+ system_announcements_email: notificationPreferences.system_announcements_email,
+ security_alerts_inapp: notificationPreferences.security_alerts_inapp,
+ security_alerts_email: notificationPreferences.security_alerts_email,
+ email_frequency: notificationPreferences.email_frequency,
+ notifications_enabled: notificationPreferences.notifications_enabled,
+ created_at: notificationPreferences.created_at,
+ updated_at: notificationPreferences.updated_at,
+ };
+ }
+
+ // 4. Listening History
+ // Fetch all listening history (may be large, but we want complete export)
+ const { data: history } = await supabase
+ .from('play_history')
+ .select('*')
+ .eq('user_id', userId)
+ .order('played_at', { ascending: false });
+
+ if (history) {
+ exportData.listening_history = history.map(item => ({
+ track_id: item.track_id,
+ track_name: item.track_name,
+ artist_name: item.artist_name,
+ album_name: item.album_name,
+ played_at: item.played_at,
+ duration_ms: item.duration_ms,
+ spotify_uri: item.spotify_uri,
+ }));
+ }
+
+ // 5. Playlists (if there's a playlists table)
+ // Note: Adjust table name and structure based on your schema
+ try {
+ const { data: playlists } = await supabase
+ .from('playlists')
+ .select('*')
+ .eq('user_id', userId)
+ .or('owner_id.eq.' + userId);
+
+ if (playlists) {
+ exportData.playlists = playlists.map(playlist => ({
+ id: playlist.id,
+ name: playlist.name,
+ description: playlist.description,
+ is_public: playlist.is_public,
+ created_at: playlist.created_at,
+ updated_at: playlist.updated_at,
+ // Note: Song list would need separate query if stored in separate table
+ }));
+ }
+ } catch (playlistError) {
+ // Table might not exist - that's okay
+ console.log('[data export] Playlists table not available:', playlistError.message);
+ }
+
+ // 6. Social Connections (if there's a connections/friends table)
+ try {
+ const { data: connections } = await supabase
+ .from('friendships')
+ .select('*')
+ .or(`user_id.eq.${userId},friend_id.eq.${userId}`);
+
+ if (connections) {
+ exportData.social_connections = connections.map(conn => ({
+ friend_id: conn.friend_id === userId ? conn.user_id : conn.friend_id,
+ status: conn.status,
+ created_at: conn.created_at,
+ }));
+ }
+ } catch (connectionError) {
+ // Table might not exist - that's okay
+ console.log('[data export] Connections table not available:', connectionError.message);
+ }
+
+ // 7. OAuth Connection Status
+ const { data: spotifyToken } = await supabase
+ .from('spotify_tokens')
+ .select('user_id, expires_at')
+ .eq('user_id', userId)
+ .single();
+
+ const { data: youtubeToken } = await supabase
+ .from('youtube_tokens')
+ .select('user_id, expires_at')
+ .eq('user_id', userId)
+ .single();
+
+ exportData.settings.oauth_connections = {
+ spotify_connected: !!spotifyToken,
+ spotify_token_expires_at: spotifyToken?.expires_at || null,
+ youtube_connected: !!youtubeToken,
+ youtube_token_expires_at: youtubeToken?.expires_at || null,
+ };
+
+ } catch (dataError) {
+ console.error('[data export] Error collecting data:', dataError);
+ // Continue with partial data
+ }
+
+ // Generate filename with timestamp
+ const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
+ const filename = `vybe-data-export-${timestamp}.json`;
+
+ // Convert to JSON string with pretty formatting
+ const jsonString = JSON.stringify(exportData, null, 2);
+
+ // Return as downloadable file
+ return new NextResponse(jsonString, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ },
+ });
+ } catch (error) {
+ console.error('[data export] Unexpected error:', error);
+ return NextResponse.json(
+ { error: 'Internal server error', message: 'Failed to generate data export' },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/apps/web/app/settings/account/page.jsx b/apps/web/app/settings/account/page.jsx
index ed93094..a2920d7 100644
--- a/apps/web/app/settings/account/page.jsx
+++ b/apps/web/app/settings/account/page.jsx
@@ -1,19 +1,128 @@
'use client';
-import { Settings as SettingsIcon } from 'lucide-react';
+import { useState, useEffect } from 'react';
+import { Settings as SettingsIcon, AlertTriangle, Trash2, Clock, Info, Download } from 'lucide-react';
import SettingsPageWrapper from '@/components/SettingsPageWrapper';
+import { useProfile } from '@/hooks/useProfileUpdate';
+import DeleteAccountModal from '@/components/DeleteAccountModal';
+
+// Inner component that uses hooks
+function AccountSettingsContent() {
+ const { data: profileData, isLoading: loading } = useProfile();
+ const [accountAge, setAccountAge] = useState(null);
+ const [isAccountTooNew, setIsAccountTooNew] = useState(false);
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [isExporting, setIsExporting] = useState(false);
+
+ // Check account age (24 hour restriction)
+ // Note: We'll get created_at from user metadata, profile data, or calculate from account creation
+ useEffect(() => {
+ // Try to get created_at from profile data first
+ if (profileData?.created_at) {
+ const createdAt = new Date(profileData.created_at);
+ const now = new Date();
+ const hoursSinceCreation = (now - createdAt) / (1000 * 60 * 60);
+ setAccountAge(hoursSinceCreation);
+ setIsAccountTooNew(hoursSinceCreation < 24);
+ } else {
+ // If created_at not in profile, we'll need to fetch it from auth.users or handle gracefully
+ // For now, assume account is old enough (will be checked on server side too)
+ setIsAccountTooNew(false);
+ }
+ }, [profileData]);
+
+ const handleDeleteClick = () => {
+ setDeleteModalOpen(true);
+ };
+
+ const handleDeleteConfirm = async (data) => {
+ setIsDeleting(true);
+
+ try {
+ const response = await fetch('/api/user/account/delete', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(data),
+ });
+
+ const result = await response.json();
+
+ if (!response.ok) {
+ // Show error message
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('show-toast', {
+ detail: {
+ type: 'error',
+ message: result.error || result.message || 'Failed to delete account',
+ },
+ }));
+ }
+ setIsDeleting(false);
+ return;
+ }
+
+ // Success - show message and redirect
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('show-toast', {
+ detail: {
+ type: 'success',
+ message: result.message || 'Account deleted successfully',
+ },
+ }));
+
+ // Redirect to home after a short delay
+ setTimeout(() => {
+ window.location.href = '/';
+ }, 2000);
+ }
+ } catch (error) {
+ console.error('Failed to delete account:', error);
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('show-toast', {
+ detail: {
+ type: 'error',
+ message: 'An error occurred while deleting your account',
+ },
+ }));
+ }
+ setIsDeleting(false);
+ }
+ };
+
+ if (loading) {
+ return (
+ <>
+
+
+
+
+
Account
+
+ Account settings and data management
+
+
+
+
+
+ >
+ );
+ }
-export default function AccountSettingsPage() {
return (
-
+
{/* Section Header */}
-
+
-
- Account
-
+
Account
Account settings and data management
@@ -22,21 +131,215 @@ export default function AccountSettingsPage() {
{/* Section Content */}
-
+
+ {/* Account Information Section */}
-
-
Account Management
-
- This section is under development. You'll be able to manage your account
- settings, export your data, and delete your account here.
-
+
Account Information
+
+
+
+
+
+
+ Manage your account settings, export your data, or permanently delete your account.
+
+
+ {/* Data Export */}
+
+
+
+
+ Export Your Data
+
+
+ Download all your account data in JSON format. This includes your profile,
+ playlists, listening history, settings, and preferences. Recommended before
+ deleting your account.
+
+
+ Rate limit: 1 export per 24 hours
+
+
+
+
+
+
+
+
+
+
+ {/* Danger Zone Section */}
+
+
-
-
-
Coming soon...
+
+
+
+
+
+
+
+ Delete Your Account
+
+
+ This action cannot be undone. Deleting your account will permanently remove:
+
+
+
+ - Your profile and all associated data
+ - All playlists you've created
+ - Your listening history and activity
+ - All social connections and friendships
+ - Group playlists and collaborations
+ - Your notification preferences and privacy settings
+
+
+ {isAccountTooNew && accountAge !== null && (
+
+
+
+
+
+ Account Protection Period
+
+
+ For security purposes, accounts less than 24 hours old cannot be deleted.
+ Your account was created {Math.floor(accountAge)} hours ago.
+ You'll be able to delete your account in {Math.ceil(24 - accountAge)} hour(s).
+
+
+
+
+ )}
+
+ {!isAccountTooNew && (
+
+
+
+
+
+ Account Deletion Process
+
+
+ Account deletion is permanent and irreversible. You'll be asked to confirm
+ your decision through a multi-step process, including typing a confirmation phrase
+ and re-entering your password.
+
+
+
+
+ )}
+
+
+
+
+
+
+ {/* Delete Account Modal */}
+
setDeleteModalOpen(false)}
+ onConfirm={handleDeleteConfirm}
+ isDeleting={isDeleting}
+ />
+
+ );
+}
+
+// Outer component that wraps content with SettingsPageWrapper
+export default function AccountSettingsPage() {
+ return (
+
+
);
}
diff --git a/apps/web/components/DeleteAccountModal.jsx b/apps/web/components/DeleteAccountModal.jsx
new file mode 100644
index 0000000..54efdf0
--- /dev/null
+++ b/apps/web/components/DeleteAccountModal.jsx
@@ -0,0 +1,374 @@
+'use client';
+
+import { useState } from 'react';
+import { X, AlertTriangle, Trash2, Download, Lock, Info } from 'lucide-react';
+
+const CONFIRMATION_PHRASE = 'DELETE MY ACCOUNT';
+
+/**
+ * DeleteAccountModal - Multi-step confirmation modal for account deletion
+ *
+ * Steps:
+ * 1. Initial warning with consequences
+ * 2. Request reason for deletion (optional feedback)
+ * 3. Type confirmation phrase
+ * 4. Final confirmation with password re-entry
+ */
+export default function DeleteAccountModal({ isOpen, onClose, onConfirm, isDeleting }) {
+ const [currentStep, setCurrentStep] = useState(1);
+ const [reason, setReason] = useState('');
+ const [confirmationPhrase, setConfirmationPhrase] = useState('');
+ const [password, setPassword] = useState('');
+ const [errors, setErrors] = useState({});
+
+ // Reset form when modal closes
+ const handleClose = () => {
+ if (!isDeleting) {
+ setCurrentStep(1);
+ setReason('');
+ setConfirmationPhrase('');
+ setPassword('');
+ setErrors({});
+ onClose();
+ }
+ };
+
+ // Handle step navigation
+ const handleNext = () => {
+ // Validate current step before proceeding
+ if (currentStep === 3) {
+ if (confirmationPhrase !== CONFIRMATION_PHRASE) {
+ setErrors({ confirmationPhrase: 'Confirmation phrase does not match' });
+ return;
+ }
+ setErrors({});
+ }
+ setCurrentStep(currentStep + 1);
+ };
+
+ const handleBack = () => {
+ if (currentStep > 1 && !isDeleting) {
+ setCurrentStep(currentStep - 1);
+ setErrors({});
+ }
+ };
+
+ // Handle final confirmation
+ const handleConfirm = () => {
+ const newErrors = {};
+
+ if (!password) {
+ newErrors.password = 'Password is required';
+ }
+
+ if (Object.keys(newErrors).length > 0) {
+ setErrors(newErrors);
+ return;
+ }
+
+ // Call parent's confirmation handler with all data
+ onConfirm({
+ password,
+ reason: reason.trim() || null,
+ confirmationPhrase,
+ });
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
{
+ // Only close if clicking the backdrop (not the modal content)
+ if (e.target === e.currentTarget && !isDeleting) {
+ handleClose();
+ }
+ }}
+ >
+
e.stopPropagation()}
+ >
+ {/* Modal Header */}
+
+
+
+
+
+ Delete Your Account
+
+
+ Step {currentStep} of 4
+
+
+
+ {!isDeleting && (
+
+ )}
+
+
+ {/* Modal Content */}
+
+ {/* Step 1: Initial Warning */}
+ {currentStep === 1 && (
+
+
+
+
+
+
+ This action cannot be undone
+
+
+ Deleting your account will permanently remove all of your data from Vybe.
+ This includes your profile, playlists, listening history, and all social connections.
+
+
+
+
+
+
+
What will be deleted:
+
+ - Your profile and all associated data
+ - All playlists you've created
+ - Your listening history and activity
+ - All social connections and friendships
+ - Group playlists and collaborations
+ - Your notification preferences and privacy settings
+
+
+
+
+
+
+
+
+ Before you continue: Make sure you've exported any data you want to keep.
+ We recommend downloading your playlists and listening history before deletion.
+
+
+
+
+
+ )}
+
+ {/* Step 2: Reason for Deletion (Optional) */}
+ {currentStep === 2 && (
+
+
+
+ Why are you deleting your account?
+
+
+ Your feedback helps us improve. This is optional and can be left blank.
+
+
+
+
+
+
+
+ Your feedback is anonymous and helps us understand how to make Vybe better.
+
+
+
+
+ )}
+
+ {/* Step 3: Confirmation Phrase */}
+ {currentStep === 3 && (
+
+
+
+ Type the confirmation phrase
+
+
+ To confirm you understand this action cannot be undone, please type:
+
+
+
+ {CONFIRMATION_PHRASE}
+
+
+
{
+ setConfirmationPhrase(e.target.value);
+ if (errors.confirmationPhrase) {
+ setErrors({ ...errors, confirmationPhrase: null });
+ }
+ }}
+ placeholder={CONFIRMATION_PHRASE}
+ className={[
+ 'w-full px-4 py-3 rounded-lg bg-white/5 border text-white placeholder-gray-500 focus:outline-none focus:ring-2',
+ errors.confirmationPhrase
+ ? 'border-red-500/50 focus:ring-red-500/50'
+ : 'border-white/20 focus:ring-red-500/50 focus:border-red-500/50',
+ ].join(' ')}
+ disabled={isDeleting}
+ autoFocus
+ />
+ {errors.confirmationPhrase && (
+
{errors.confirmationPhrase}
+ )}
+
+
+ )}
+
+ {/* Step 4: Password Confirmation */}
+ {currentStep === 4 && (
+
+
+
+ Re-enter your password
+
+
+ For security, please enter your password to confirm account deletion.
+
+
+ {
+ setPassword(e.target.value);
+ if (errors.password) {
+ setErrors({ ...errors, password: null });
+ }
+ }}
+ placeholder="Enter your password"
+ className={[
+ 'w-full px-4 py-3 pl-12 rounded-lg bg-white/5 border text-white placeholder-gray-500 focus:outline-none focus:ring-2',
+ errors.password
+ ? 'border-red-500/50 focus:ring-red-500/50'
+ : 'border-white/20 focus:ring-red-500/50 focus:border-red-500/50',
+ ].join(' ')}
+ disabled={isDeleting}
+ autoFocus
+ />
+
+
+ {errors.password && (
+
{errors.password}
+ )}
+
+
+
+
+
+
+
+ Final Warning
+
+
+ Once you confirm, your account and all associated data will be permanently deleted.
+ This cannot be undone or recovered.
+
+
+
+
+
+ )}
+
+ {/* Progress Indicator */}
+
+ {[1, 2, 3, 4].map((step) => (
+
+ ))}
+
+
+
+ {/* Modal Footer */}
+
+
+
+
+ {!isDeleting && (
+
+ )}
+
+ {currentStep < 4 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
diff --git a/apps/web/lib/jobs/accountDeletionJob.js b/apps/web/lib/jobs/accountDeletionJob.js
new file mode 100644
index 0000000..d052bb8
--- /dev/null
+++ b/apps/web/lib/jobs/accountDeletionJob.js
@@ -0,0 +1,207 @@
+/**
+ * Account Deletion Job
+ *
+ * Scheduled job for processing pending account deletions after grace period.
+ * This module provides the job logic that can be called by cron jobs,
+ * Supabase Edge Functions, or other schedulers.
+ */
+
+import { createClient } from '@supabase/supabase-js';
+import { deleteAccount } from '@/lib/services/accountDeletion';
+
+/**
+ * Run account deletion job
+ *
+ * Processes accounts marked for deletion that have passed their grace period.
+ *
+ * @param {Object} options - Job options
+ * @param {string} options.supabaseUrl - Supabase project URL
+ * @param {string} options.supabaseServiceKey - Supabase service role key
+ * @param {number} options.gracePeriodDays - Grace period in days (default: 7)
+ * @param {number} options.batchSize - Number of accounts to process per run (default: 100)
+ * @returns {Promise
- {/* Mobile menu button */}
-
+
+ {/* Settings Sync Indicator */}
+
+
+ {/* Mobile menu button */}
+
+
diff --git a/apps/web/components/SettingsSyncIndicator.jsx b/apps/web/components/SettingsSyncIndicator.jsx
new file mode 100644
index 0000000..8b971f8
--- /dev/null
+++ b/apps/web/components/SettingsSyncIndicator.jsx
@@ -0,0 +1,179 @@
+'use client';
+
+import { CheckCircle2, RefreshCw, AlertCircle, WifiOff, Wifi } from 'lucide-react';
+import { useSettingsSync } from '@/hooks/useSettingsSync';
+import { useState, useEffect } from 'react';
+
+/**
+ * Settings Sync Indicator
+ *
+ * Visual feedback for settings sync status showing:
+ * - Synced (green checkmark)
+ * - Syncing (spinning icon)
+ * - Error (warning icon)
+ * - Offline (wifi off icon)
+ *
+ * Includes tooltip explaining current state.
+ *
+ * @param {Object} options - Configuration options
+ * @param {boolean} options.showNotifications - Show toast notifications (default: true)
+ * @param {string} options.conflictResolution - Conflict resolution strategy (default: 'remote')
+ * @param {string} options.className - Additional CSS classes
+ * @returns {JSX.Element} Sync indicator component
+ */
+export default function SettingsSyncIndicator({
+ showNotifications = true,
+ conflictResolution = 'remote',
+ className = '',
+}) {
+ const sync = useSettingsSync({
+ enabled: true,
+ showNotifications,
+ conflictResolution,
+ });
+
+ const [showTooltip, setShowTooltip] = useState(false);
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) {
+ return null;
+ }
+
+ // Determine sync status
+ const getSyncStatus = () => {
+ if (!sync.isOnline) {
+ return {
+ icon: WifiOff,
+ color: 'text-gray-400',
+ bgColor: 'bg-gray-500/10',
+ borderColor: 'border-gray-500/20',
+ status: 'offline',
+ label: 'Offline',
+ tooltip: 'No internet connection. Changes will sync when you come back online.',
+ };
+ }
+
+ if (sync.isSyncing) {
+ return {
+ icon: RefreshCw,
+ color: 'text-blue-400',
+ bgColor: 'bg-blue-500/10',
+ borderColor: 'border-blue-500/20',
+ status: 'syncing',
+ label: 'Syncing...',
+ tooltip: 'Syncing settings across devices...',
+ animate: true,
+ };
+ }
+
+ if (sync.queuedUpdatesCount > 0) {
+ return {
+ icon: AlertCircle,
+ color: 'text-yellow-400',
+ bgColor: 'bg-yellow-500/10',
+ borderColor: 'border-yellow-500/20',
+ status: 'queued',
+ label: `${sync.queuedUpdatesCount} queued`,
+ tooltip: `${sync.queuedUpdatesCount} update(s) queued. Will sync when online.`,
+ };
+ }
+
+ if (sync.subscriptionsActive) {
+ return {
+ icon: CheckCircle2,
+ color: 'text-green-400',
+ bgColor: 'bg-green-500/10',
+ borderColor: 'border-green-500/20',
+ status: 'synced',
+ label: 'Synced',
+ tooltip: 'Settings are synced in real-time across all your devices.',
+ };
+ }
+
+ // Default: connecting
+ return {
+ icon: RefreshCw,
+ color: 'text-gray-400',
+ bgColor: 'bg-gray-500/10',
+ borderColor: 'border-gray-500/20',
+ status: 'connecting',
+ label: 'Connecting...',
+ tooltip: 'Connecting to sync service...',
+ animate: true,
+ };
+ };
+
+ const status = getSyncStatus();
+ const Icon = status.icon;
+
+ return (
+
+
+
+ {/* Tooltip */}
+ {showTooltip && (
+
+ )}
+
+ );
+}
+
diff --git a/apps/web/hooks/useAutoSave.js b/apps/web/hooks/useAutoSave.js
new file mode 100644
index 0000000..b39b1fc
--- /dev/null
+++ b/apps/web/hooks/useAutoSave.js
@@ -0,0 +1,272 @@
+'use client';
+
+import { useEffect, useRef, useState, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import useSettingsStore from '@/store/settingsStore';
+
+/**
+ * Auto-Save Hook
+ *
+ * Implements auto-save functionality for settings with:
+ * - Debounced save after user stops typing (2 seconds)
+ * - Visual indicator showing save status (Saving..., Saved, Error)
+ * - Retry failed saves
+ * - Prevent navigation away with unsaved changes
+ * - Show warning before leaving page with unsaved data
+ * - Use TanStack Query mutations with optimistic updates
+ *
+ * @param {Object} options - Configuration options
+ * @param {string} options.type - Settings type: 'profile', 'privacy', or 'notifications'
+ * @param {Function} options.mutationFn - TanStack Query mutation function
+ * @param {number} options.debounceMs - Debounce delay in milliseconds (default: 2000)
+ * @param {boolean} options.enableBeforeUnload - Enable beforeunload warning (default: true)
+ * @param {boolean} options.enableRouteBlock - Enable route change blocking (default: true)
+ * @param {number} options.maxRetries - Maximum retry attempts (default: 3)
+ * @returns {Object} Auto-save state and controls
+ */
+export function useAutoSave(options = {}) {
+ const {
+ type,
+ mutationFn,
+ debounceMs = 2000,
+ enableBeforeUnload = true,
+ enableRouteBlock = true,
+ maxRetries = 3,
+ } = options;
+
+ const [saveStatus, setSaveStatus] = useState('idle'); // 'idle', 'saving', 'saved', 'error'
+ const [lastSaved, setLastSaved] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [retryCount, setRetryCount] = useState(0);
+
+ const store = useSettingsStore();
+ const router = useRouter();
+ const debounceTimerRef = useRef(null);
+ const isUnmountingRef = useRef(false);
+ const pendingSaveRef = useRef(null);
+
+ // Get current settings data
+ const getCurrentData = useCallback(() => {
+ switch (type) {
+ case 'profile':
+ return store.profile;
+ case 'privacy':
+ return store.privacy;
+ case 'notifications':
+ return store.notifications;
+ default:
+ return null;
+ }
+ }, [type, store]);
+
+ // Check if settings are dirty
+ const isDirty = store.isDirty[type];
+
+ // Auto-save function
+ const performSave = useCallback(async (data, isRetry = false) => {
+ if (!data || !mutationFn) return;
+
+ setSaveStatus('saving');
+ setErrorMessage(null);
+
+ try {
+ // Perform mutation
+ const result = await mutationFn(data);
+
+ if (result && result.error) {
+ throw new Error(result.error);
+ }
+
+ // Success
+ setSaveStatus('saved');
+ setLastSaved(new Date());
+ setRetryCount(0);
+
+ // Clear dirty state
+ store.clearDirty(type);
+
+ // Clear saved status after 3 seconds
+ setTimeout(() => {
+ if (!isUnmountingRef.current && saveStatus === 'saved') {
+ setSaveStatus('idle');
+ }
+ }, 3000);
+
+ return { success: true, data: result };
+ } catch (error) {
+ console.error(`[auto-save] Error saving ${type}:`, error);
+
+ setSaveStatus('error');
+ setErrorMessage(error.message || 'Failed to save');
+
+ // Auto-retry on error (up to maxRetries)
+ if (!isRetry && retryCount < maxRetries) {
+ const newRetryCount = retryCount + 1;
+ setRetryCount(newRetryCount);
+
+ // Exponential backoff: 1s, 2s, 4s
+ const delay = Math.pow(2, newRetryCount - 1) * 1000;
+
+ setTimeout(() => {
+ if (!isUnmountingRef.current) {
+ performSave(data, true);
+ }
+ }, delay);
+ } else {
+ // Max retries reached
+ setErrorMessage(
+ error.message || `Failed to save after ${maxRetries} attempts`
+ );
+ }
+
+ return { success: false, error: error.message };
+ }
+ }, [type, mutationFn, retryCount, maxRetries, store]);
+
+ // Debounced save
+ const debouncedSave = useCallback(() => {
+ // Clear existing timer
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+
+ // Set new timer
+ debounceTimerRef.current = setTimeout(() => {
+ const data = getCurrentData();
+ if (data && isDirty) {
+ pendingSaveRef.current = data;
+ performSave(data);
+ }
+ }, debounceMs);
+ }, [getCurrentData, isDirty, debounceMs, performSave]);
+
+ // Trigger auto-save when settings change
+ useEffect(() => {
+ if (!isDirty || !mutationFn) return;
+
+ // Reset save status when settings change
+ if (saveStatus === 'saved') {
+ setSaveStatus('idle');
+ }
+
+ // Trigger debounced save
+ debouncedSave();
+
+ // Cleanup on unmount
+ return () => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+ };
+ }, [isDirty, debouncedSave, mutationFn, saveStatus]);
+
+ // Save immediately (manual trigger)
+ const saveNow = useCallback(async () => {
+ // Clear debounce timer
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ debounceTimerRef.current = null;
+ }
+
+ const data = getCurrentData();
+ if (data) {
+ return await performSave(data);
+ }
+ }, [getCurrentData, performSave]);
+
+ // Retry failed save
+ const retrySave = useCallback(async () => {
+ const data = pendingSaveRef.current || getCurrentData();
+ if (data) {
+ setRetryCount(0);
+ return await performSave(data, false);
+ }
+ }, [getCurrentData, performSave]);
+
+ // Before unload warning
+ useEffect(() => {
+ if (!enableBeforeUnload || !isDirty) return;
+
+ const handleBeforeUnload = (e) => {
+ if (isDirty) {
+ e.preventDefault();
+ e.returnValue = 'You have unsaved changes. Are you sure you want to leave?';
+ return e.returnValue;
+ }
+ };
+
+ window.addEventListener('beforeunload', handleBeforeUnload);
+
+ return () => {
+ window.removeEventListener('beforeunload', handleBeforeUnload);
+ };
+ }, [enableBeforeUnload, isDirty]);
+
+ // Route change blocking
+ useEffect(() => {
+ if (!enableRouteBlock) return;
+
+ // Note: Next.js App Router doesn't have a direct way to block navigation
+ // We can use a custom event to communicate with the router
+ // For now, we'll rely on beforeunload and manual checks
+ // Future: Could use a router middleware or custom navigation handler
+ }, [enableRouteBlock]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ isUnmountingRef.current = false;
+
+ return () => {
+ isUnmountingRef.current = true;
+
+ // Save any pending changes before unmount
+ if (isDirty && pendingSaveRef.current) {
+ // Attempt to save synchronously (may not complete)
+ const data = pendingSaveRef.current;
+ if (data && mutationFn) {
+ mutationFn(data).catch((error) => {
+ console.error(`[auto-save] Error saving ${type} on unmount:`, error);
+ });
+ }
+ }
+
+ // Clear timers
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+ };
+ }, [isDirty, type, mutationFn]);
+
+ // Save status indicator text
+ const statusText = {
+ idle: '',
+ saving: 'Saving...',
+ saved: 'Saved',
+ error: 'Error saving',
+ }[saveStatus];
+
+ return {
+ // State
+ saveStatus,
+ statusText,
+ isDirty,
+ lastSaved,
+ errorMessage,
+ retryCount,
+ maxRetries,
+ canRetry: saveStatus === 'error' && retryCount < maxRetries,
+
+ // Actions
+ saveNow,
+ retrySave,
+
+ // Utilities
+ clearError: () => {
+ setErrorMessage(null);
+ if (saveStatus === 'error') {
+ setSaveStatus('idle');
+ }
+ },
+ };
+}
+
diff --git a/apps/web/hooks/useNotificationPreferences.js b/apps/web/hooks/useNotificationPreferences.js
index a215fbd..41bf6fd 100644
--- a/apps/web/hooks/useNotificationPreferences.js
+++ b/apps/web/hooks/useNotificationPreferences.js
@@ -1,10 +1,16 @@
'use client';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { getSettingsQueryOptions, invalidateOnUpdate } from '@/lib/cache/settingsCache';
/**
* Fetch user notification preferences
*
+ * Uses optimized cache settings:
+ * - 5 minute stale time
+ * - Fallback to stale cache if API fails
+ * - Background refetch on window focus
+ *
* @returns {Object} Query object with notification preferences, loading, and error states
*/
export function useNotificationPreferences() {
@@ -18,7 +24,7 @@ export function useNotificationPreferences() {
}
return await response.json();
},
- staleTime: 60 * 1000, // 1 minute
+ ...getSettingsQueryOptions(),
});
}
@@ -85,8 +91,8 @@ export function useNotificationPreferencesUpdate() {
// Update cache with server response
queryClient.setQueryData(['notificationPreferences'], data);
- // Invalidate and refetch
- queryClient.invalidateQueries({ queryKey: ['notificationPreferences'] });
+ // Invalidate cache on explicit update
+ invalidateOnUpdate(queryClient, 'notifications');
// Show success notification
if (typeof window !== 'undefined') {
diff --git a/apps/web/hooks/usePrivacySettings.js b/apps/web/hooks/usePrivacySettings.js
index 07abf80..c682edf 100644
--- a/apps/web/hooks/usePrivacySettings.js
+++ b/apps/web/hooks/usePrivacySettings.js
@@ -1,10 +1,16 @@
'use client';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { getSettingsQueryOptions, invalidateOnUpdate } from '@/lib/cache/settingsCache';
/**
* Fetch user privacy settings
*
+ * Uses optimized cache settings:
+ * - 5 minute stale time
+ * - Fallback to stale cache if API fails
+ * - Background refetch on window focus
+ *
* @returns {Object} Query object with privacy settings, loading, and error states
*/
export function usePrivacySettings() {
@@ -18,7 +24,7 @@ export function usePrivacySettings() {
}
return await response.json();
},
- staleTime: 60 * 1000, // 1 minute
+ ...getSettingsQueryOptions(),
});
}
@@ -85,8 +91,8 @@ export function usePrivacySettingsUpdate() {
// Update cache with server response
queryClient.setQueryData(['privacy'], data);
- // Invalidate and refetch
- queryClient.invalidateQueries({ queryKey: ['privacy'] });
+ // Invalidate cache on explicit update
+ invalidateOnUpdate(queryClient, 'privacy');
// Show success notification
if (typeof window !== 'undefined') {
diff --git a/apps/web/hooks/useProfileUpdate.js b/apps/web/hooks/useProfileUpdate.js
index ada75ec..365c09b 100644
--- a/apps/web/hooks/useProfileUpdate.js
+++ b/apps/web/hooks/useProfileUpdate.js
@@ -1,10 +1,16 @@
'use client';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { getSettingsQueryOptions, invalidateOnUpdate } from '@/lib/cache/settingsCache';
/**
* Fetch user profile data
*
+ * Uses optimized cache settings:
+ * - 5 minute stale time
+ * - Fallback to stale cache if API fails
+ * - Background refetch on window focus
+ *
* @returns {Object} Query object with profile data, loading, and error states
*/
export function useProfile() {
@@ -17,7 +23,7 @@ export function useProfile() {
}
return await response.json();
},
- staleTime: 60 * 1000, // 1 minute
+ ...getSettingsQueryOptions(),
});
}
@@ -108,8 +114,8 @@ export function useProfileUpdate() {
// Update cache with server response
queryClient.setQueryData(['profile'], data);
- // Invalidate related queries to ensure consistency
- queryClient.invalidateQueries({ queryKey: ['profile'] });
+ // Invalidate cache on explicit update
+ invalidateOnUpdate(queryClient, 'profile');
// Show success notification
if (typeof window !== 'undefined') {
diff --git a/apps/web/hooks/useSettingsMigration.js b/apps/web/hooks/useSettingsMigration.js
new file mode 100644
index 0000000..5dc21e2
--- /dev/null
+++ b/apps/web/hooks/useSettingsMigration.js
@@ -0,0 +1,66 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import useSettingsStore from '@/store/settingsStore';
+import { autoMigrateSettings, needsMigration } from '@/lib/migrations/settingsMigrations';
+
+/**
+ * Settings Migration Hook
+ *
+ * Automatically runs settings migrations on user login/data load.
+ *
+ * Usage:
+ * ```jsx
+ * function SettingsPage() {
+ * useSettingsMigration();
+ * // ... rest of component
+ * }
+ * ```
+ */
+export function useSettingsMigration() {
+ const store = useSettingsStore();
+ const hasRunMigrationRef = useRef(false);
+
+ useEffect(() => {
+ // Only run once per mount
+ if (hasRunMigrationRef.current) return;
+
+ // Check if migration is needed
+ if (!needsMigration()) {
+ hasRunMigrationRef.current = true;
+ return;
+ }
+
+ // Run migration on store data
+ const storeState = useSettingsStore.getState();
+
+ const settings = {
+ profile: storeState.profile,
+ privacy: storeState.privacy,
+ notifications: storeState.notifications,
+ };
+
+ // Migrate settings
+ const migrated = autoMigrateSettings(settings);
+
+ // Update store with migrated data
+ if (migrated.profile && migrated.profile !== storeState.profile) {
+ storeState.setProfile(migrated.profile, { optimistic: false, skipDirty: true });
+ }
+
+ if (migrated.privacy && migrated.privacy !== storeState.privacy) {
+ storeState.setPrivacy(migrated.privacy, { optimistic: false, skipDirty: true });
+ }
+
+ if (migrated.notifications && migrated.notifications !== storeState.notifications) {
+ storeState.setNotifications(migrated.notifications, { optimistic: false, skipDirty: true });
+ }
+
+ hasRunMigrationRef.current = true;
+
+ console.log('[settings migration] Migration completed');
+ }, []);
+
+ return null;
+}
+
diff --git a/apps/web/hooks/useSettingsSync.js b/apps/web/hooks/useSettingsSync.js
new file mode 100644
index 0000000..8aa68f8
--- /dev/null
+++ b/apps/web/hooks/useSettingsSync.js
@@ -0,0 +1,514 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { supabaseBrowser } from '@/lib/supabase/client';
+import useSettingsStore from '@/store/settingsStore';
+import { invalidateOnRealtimeUpdate } from '@/lib/cache/settingsCache';
+
+/**
+ * Settings Sync Hook
+ *
+ * Syncs settings across tabs/devices using Supabase realtime subscriptions.
+ * Features:
+ * - Realtime subscriptions to settings tables
+ * - Update local state when remote changes detected
+ * - Show notification when settings updated elsewhere
+ * - Handle offline/online state
+ * - Queue updates when offline, sync when back online
+ * - Resolve conflicts (last write wins or user choice)
+ *
+ * @param {Object} options - Configuration options
+ * @param {boolean} options.enabled - Enable/disable sync (default: true)
+ * @param {boolean} options.showNotifications - Show toast notifications (default: true)
+ * @param {string} options.conflictResolution - 'remote', 'local', or 'prompt' (default: 'remote')
+ * @returns {Object} Sync state and controls
+ */
+export function useSettingsSync(options = {}) {
+ const {
+ enabled = true,
+ showNotifications = true,
+ conflictResolution = 'remote', // 'remote', 'local', 'prompt'
+ } = options;
+
+ const [isOnline, setIsOnline] = useState(
+ typeof window !== 'undefined' ? navigator.onLine : true
+ );
+ const [queuedUpdates, setQueuedUpdates] = useState([]);
+ const [isSyncing, setIsSyncing] = useState(false);
+
+ const store = useSettingsStore();
+ const queryClient = useQueryClient();
+ const subscriptionsRef = useRef([]);
+ const userIdRef = useRef(null);
+ const lastSyncRef = useRef({
+ profile: null,
+ privacy: null,
+ notifications: null,
+ });
+
+ // Track online/offline state
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+
+ const handleOnline = () => {
+ setIsOnline(true);
+ // Sync queued updates when coming back online
+ if (queuedUpdates.length > 0) {
+ processQueuedUpdates();
+ }
+ };
+
+ const handleOffline = () => {
+ setIsOnline(false);
+ };
+
+ window.addEventListener('online', handleOnline);
+ window.addEventListener('offline', handleOffline);
+
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ window.removeEventListener('offline', handleOffline);
+ };
+ }, [queuedUpdates]);
+
+ // Get current user
+ useEffect(() => {
+ if (!enabled) return;
+
+ const getUserId = async () => {
+ try {
+ const supabase = supabaseBrowser();
+ const { data: { user }, error } = await supabase.auth.getUser();
+
+ if (error || !user) {
+ console.warn('[settings sync] No authenticated user');
+ return;
+ }
+
+ userIdRef.current = user.id;
+ setupSubscriptions(user.id);
+ } catch (error) {
+ console.error('[settings sync] Error getting user:', error);
+ }
+ };
+
+ getUserId();
+
+ return () => {
+ // Cleanup subscriptions
+ subscriptionsRef.current.forEach((subscription) => {
+ if (subscription) {
+ subscription.unsubscribe();
+ }
+ });
+ subscriptionsRef.current = [];
+ };
+ }, [enabled]);
+
+ // Setup Supabase realtime subscriptions
+ const setupSubscriptions = (userId) => {
+ const supabase = supabaseBrowser();
+
+ // Cleanup existing subscriptions
+ subscriptionsRef.current.forEach((sub) => {
+ if (sub) sub.unsubscribe();
+ });
+ subscriptionsRef.current = [];
+
+ // Subscribe to profile changes (users table)
+ const profileSubscription = supabase
+ .channel(`profile-changes-${userId}`)
+ .on(
+ 'postgres_changes',
+ {
+ event: '*', // INSERT, UPDATE, DELETE
+ schema: 'public',
+ table: 'users',
+ filter: `id=eq.${userId}`,
+ },
+ (payload) => {
+ handleSettingsChange('profile', payload, userId);
+ }
+ )
+ .subscribe((status) => {
+ if (status === 'SUBSCRIBED') {
+ console.log('[settings sync] Profile subscription active');
+ } else if (status === 'CHANNEL_ERROR') {
+ console.error('[settings sync] Profile subscription error');
+ }
+ });
+
+ // Subscribe to privacy settings changes
+ const privacySubscription = supabase
+ .channel(`privacy-changes-${userId}`)
+ .on(
+ 'postgres_changes',
+ {
+ event: '*',
+ schema: 'public',
+ table: 'user_privacy_settings',
+ filter: `user_id=eq.${userId}`,
+ },
+ (payload) => {
+ handleSettingsChange('privacy', payload, userId);
+ }
+ )
+ .subscribe((status) => {
+ if (status === 'SUBSCRIBED') {
+ console.log('[settings sync] Privacy subscription active');
+ } else if (status === 'CHANNEL_ERROR') {
+ console.error('[settings sync] Privacy subscription error');
+ }
+ });
+
+ // Subscribe to notification preferences changes
+ const notificationsSubscription = supabase
+ .channel(`notifications-changes-${userId}`)
+ .on(
+ 'postgres_changes',
+ {
+ event: '*',
+ schema: 'public',
+ table: 'user_notification_preferences',
+ filter: `user_id=eq.${userId}`,
+ },
+ (payload) => {
+ handleSettingsChange('notifications', payload, userId);
+ }
+ )
+ .subscribe((status) => {
+ if (status === 'SUBSCRIBED') {
+ console.log('[settings sync] Notifications subscription active');
+ } else if (status === 'CHANNEL_ERROR') {
+ console.error('[settings sync] Notifications subscription error');
+ }
+ });
+
+ subscriptionsRef.current = [
+ profileSubscription,
+ privacySubscription,
+ notificationsSubscription,
+ ];
+ };
+
+ // Handle settings change from realtime
+ const handleSettingsChange = async (type, payload, userId) => {
+ try {
+ // Ignore if this is our own change (check last sync timestamp)
+ const lastSync = lastSyncRef.current[type];
+ const now = new Date().toISOString();
+ const eventTimestamp = payload.commit_timestamp || now;
+
+ // Skip if this is likely our own update (within 1 second of our last sync)
+ if (lastSync && eventTimestamp) {
+ const timeDiff = new Date(eventTimestamp) - new Date(lastSync);
+ if (timeDiff < 1000) {
+ console.log(`[settings sync] Ignoring own ${type} update`);
+ return;
+ }
+ }
+
+ // If offline, queue the update
+ if (!isOnline) {
+ setQueuedUpdates((prev) => [...prev, { type, payload, timestamp: now }]);
+ return;
+ }
+
+ // Fetch fresh data from API
+ const freshData = await fetchSettingsData(type, userId);
+
+ if (!freshData) {
+ console.warn(`[settings sync] Failed to fetch ${type} data`);
+ return;
+ }
+
+ // Check for conflicts
+ const hasConflict = checkConflict(type, freshData);
+
+ if (hasConflict) {
+ await handleConflict(type, freshData);
+ } else {
+ // No conflict, update store
+ updateStoreWithRemoteData(type, freshData);
+
+ // Invalidate cache on realtime update
+ invalidateOnRealtimeUpdate(queryClient, type);
+
+ if (showNotifications) {
+ showSettingsUpdatedNotification(type);
+ }
+ }
+ } catch (error) {
+ console.error(`[settings sync] Error handling ${type} change:`, error);
+ }
+ };
+
+ // Fetch fresh settings data from API
+ const fetchSettingsData = async (type, userId) => {
+ try {
+ let endpoint;
+
+ switch (type) {
+ case 'profile':
+ endpoint = '/api/user/profile';
+ break;
+ case 'privacy':
+ endpoint = '/api/user/privacy';
+ break;
+ case 'notifications':
+ endpoint = '/api/user/notifications';
+ break;
+ default:
+ return null;
+ }
+
+ const response = await fetch(endpoint);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch ${type} settings`);
+ }
+
+ const data = await response.json();
+ return data;
+ } catch (error) {
+ console.error(`[settings sync] Error fetching ${type}:`, error);
+ return null;
+ }
+ };
+
+ // Check if there's a conflict between local and remote data
+ const checkConflict = (type, remoteData) => {
+ const storeState = useSettingsStore.getState();
+ let localData;
+
+ switch (type) {
+ case 'profile':
+ localData = storeState.profile;
+ break;
+ case 'privacy':
+ localData = storeState.privacy;
+ break;
+ case 'notifications':
+ localData = storeState.notifications;
+ break;
+ default:
+ return false;
+ }
+
+ const isDirty = storeState.isDirty[type];
+
+ // If local isn't dirty, no conflict
+ if (!isDirty) {
+ return false;
+ }
+
+ // Check if data has actually changed (simple comparison)
+ // In production, you might want more sophisticated conflict detection
+ const localString = JSON.stringify(localData);
+ const remoteString = JSON.stringify(remoteData);
+
+ return localString !== remoteString;
+ };
+
+ // Handle conflict resolution
+ const handleConflict = async (type, remoteData) => {
+ const storeState = useSettingsStore.getState();
+ let localData;
+
+ switch (type) {
+ case 'profile':
+ localData = storeState.profile;
+ break;
+ case 'privacy':
+ localData = storeState.privacy;
+ break;
+ case 'notifications':
+ localData = storeState.notifications;
+ break;
+ default:
+ return;
+ }
+
+ if (conflictResolution === 'remote') {
+ // Last write wins (remote)
+ updateStoreWithRemoteData(type, remoteData);
+
+ if (showNotifications) {
+ showConflictResolvedNotification(type, 'remote');
+ }
+ } else if (conflictResolution === 'local') {
+ // Keep local changes
+ // Don't update store, but mark conflict
+ useSettingsStore.setState((state) => ({
+ conflicts: {
+ ...state.conflicts,
+ [type]: {
+ local: localData,
+ remote: remoteData,
+ detectedAt: new Date().toISOString(),
+ },
+ },
+ }));
+
+ if (showNotifications) {
+ showConflictNotification(type);
+ }
+ } else if (conflictResolution === 'prompt') {
+ // Show conflict notification (UI should prompt user)
+ useSettingsStore.setState((state) => ({
+ conflicts: {
+ ...state.conflicts,
+ [type]: {
+ local: localData,
+ remote: remoteData,
+ detectedAt: new Date().toISOString(),
+ },
+ },
+ }));
+
+ if (showNotifications) {
+ showConflictPromptNotification(type);
+ }
+ }
+ };
+
+ // Update store with remote data
+ const updateStoreWithRemoteData = (type, remoteData) => {
+ const storeState = useSettingsStore.getState();
+
+ switch (type) {
+ case 'profile':
+ storeState.setProfile(remoteData, { optimistic: false, skipDirty: true });
+ break;
+ case 'privacy':
+ storeState.setPrivacy(remoteData, { optimistic: false, skipDirty: true });
+ break;
+ case 'notifications':
+ storeState.setNotifications(remoteData, { optimistic: false, skipDirty: true });
+ break;
+ }
+
+ // Update last sync timestamp
+ lastSyncRef.current[type] = new Date().toISOString();
+ };
+
+ // Process queued updates when coming back online
+ const processQueuedUpdates = async () => {
+ if (!isOnline || queuedUpdates.length === 0) return;
+
+ setIsSyncing(true);
+
+ try {
+ const updates = [...queuedUpdates];
+ setQueuedUpdates([]);
+
+ for (const update of updates) {
+ const userId = userIdRef.current;
+ if (!userId) continue;
+
+ const freshData = await fetchSettingsData(update.type, userId);
+ if (freshData) {
+ updateStoreWithRemoteData(update.type, freshData);
+ }
+ }
+
+ if (showNotifications && updates.length > 0) {
+ showSyncCompleteNotification(updates.length);
+ }
+ } catch (error) {
+ console.error('[settings sync] Error processing queued updates:', error);
+ } finally {
+ setIsSyncing(false);
+ }
+ };
+
+ // Show toast notifications
+ const showSettingsUpdatedNotification = (type) => {
+ const typeLabels = {
+ profile: 'Profile',
+ privacy: 'Privacy settings',
+ notifications: 'Notification preferences',
+ };
+
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('show-toast', {
+ detail: {
+ type: 'info',
+ message: `${typeLabels[type]} were updated on another device`,
+ },
+ }));
+ }
+ };
+
+ const showConflictNotification = (type) => {
+ const typeLabels = {
+ profile: 'Profile',
+ privacy: 'Privacy settings',
+ notifications: 'Notification preferences',
+ };
+
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('show-toast', {
+ detail: {
+ type: 'warning',
+ message: `Conflict detected in ${typeLabels[type]}. Local changes preserved.`,
+ },
+ }));
+ }
+ };
+
+ const showConflictPromptNotification = (type) => {
+ const typeLabels = {
+ profile: 'Profile',
+ privacy: 'Privacy settings',
+ notifications: 'Notification preferences',
+ };
+
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('show-toast', {
+ detail: {
+ type: 'warning',
+ message: `Conflict in ${typeLabels[type]}. Please resolve manually.`,
+ duration: 10000, // Longer duration for conflict
+ },
+ }));
+ }
+ };
+
+ const showConflictResolvedNotification = (type, resolution) => {
+ const typeLabels = {
+ profile: 'Profile',
+ privacy: 'Privacy settings',
+ notifications: 'Notification preferences',
+ };
+
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('show-toast', {
+ detail: {
+ type: 'info',
+ message: `${typeLabels[type]} updated from another device (${resolution} changes kept)`,
+ },
+ }));
+ }
+ };
+
+ const showSyncCompleteNotification = (count) => {
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('show-toast', {
+ detail: {
+ type: 'success',
+ message: `Synced ${count} setting update${count > 1 ? 's' : ''} from offline queue`,
+ },
+ }));
+ }
+ };
+
+ return {
+ isOnline,
+ isSyncing,
+ queuedUpdatesCount: queuedUpdates.length,
+ subscriptionsActive: subscriptionsRef.current.length > 0,
+ processQueuedUpdates,
+ };
+}
+
diff --git a/apps/web/lib/cache/settingsCache.js b/apps/web/lib/cache/settingsCache.js
new file mode 100644
index 0000000..0b9edc1
--- /dev/null
+++ b/apps/web/lib/cache/settingsCache.js
@@ -0,0 +1,282 @@
+/**
+ * Settings Cache Strategy
+ *
+ * Optimizes settings loading with smart caching using TanStack Query.
+ * Features:
+ * - Cache settings in memory using TanStack Query
+ * - Set appropriate stale time (5 minutes for settings)
+ * - Prefetch settings on app load
+ * - Cache invalidation strategies
+ * - Fallback to stale cache if API fails
+ * - Background refetch on window focus
+ */
+
+import { QueryClient } from '@tanstack/react-query';
+
+// Cache configuration constants
+export const SETTINGS_CACHE_CONFIG = {
+ STALE_TIME: 5 * 60 * 1000, // 5 minutes
+ CACHE_TIME: 10 * 60 * 1000, // 10 minutes (keep in cache)
+ REFETCH_ON_WINDOW_FOCUS: true,
+ REFETCH_ON_MOUNT: false, // Don't refetch if we have cached data
+ RETRY: 1, // Retry once on failure
+ RETRY_DELAY: 1000, // 1 second delay
+};
+
+// Query keys for settings
+export const SETTINGS_QUERY_KEYS = {
+ profile: ['profile'],
+ privacy: ['privacy'],
+ notifications: ['notificationPreferences'],
+ all: ['profile', 'privacy', 'notificationPreferences'],
+};
+
+/**
+ * Get query options for settings queries
+ * @param {Object} options - Query options
+ * @returns {Object} TanStack Query options
+ */
+export function getSettingsQueryOptions(options = {}) {
+ return {
+ staleTime: SETTINGS_CACHE_CONFIG.STALE_TIME,
+ gcTime: SETTINGS_CACHE_CONFIG.CACHE_TIME, // Previously cacheTime in v4
+ refetchOnWindowFocus: SETTINGS_CACHE_CONFIG.REFETCH_ON_WINDOW_FOCUS,
+ refetchOnMount: options.refetchOnMount ?? SETTINGS_CACHE_CONFIG.REFETCH_ON_MOUNT,
+ retry: SETTINGS_CACHE_CONFIG.RETRY,
+ retryDelay: SETTINGS_CACHE_CONFIG.RETRY_DELAY,
+ // Fallback to stale cache if API fails
+ placeholderData: (previousData) => previousData,
+ ...options,
+ };
+}
+
+/**
+ * Prefetch all settings on app load
+ * @param {QueryClient} queryClient - TanStack Query client
+ * @returns {Promise} Promise that resolves when prefetch completes
+ */
+export async function prefetchSettings(queryClient) {
+ try {
+ console.log('[settings cache] Prefetching all settings...');
+
+ const prefetchPromises = [
+ prefetchProfile(queryClient),
+ prefetchPrivacy(queryClient),
+ prefetchNotifications(queryClient),
+ ];
+
+ await Promise.allSettled(prefetchPromises);
+
+ console.log('[settings cache] Prefetch completed');
+ } catch (error) {
+ console.error('[settings cache] Error prefetching settings:', error);
+ }
+}
+
+/**
+ * Prefetch profile settings
+ * @param {QueryClient} queryClient - TanStack Query client
+ */
+export async function prefetchProfile(queryClient) {
+ await queryClient.prefetchQuery({
+ queryKey: SETTINGS_QUERY_KEYS.profile,
+ queryFn: async () => {
+ const response = await fetch('/api/user/profile');
+ if (!response.ok) {
+ throw new Error('Failed to fetch profile');
+ }
+ return await response.json();
+ },
+ ...getSettingsQueryOptions(),
+ });
+}
+
+/**
+ * Prefetch privacy settings
+ * @param {QueryClient} queryClient - TanStack Query client
+ */
+export async function prefetchPrivacy(queryClient) {
+ await queryClient.prefetchQuery({
+ queryKey: SETTINGS_QUERY_KEYS.privacy,
+ queryFn: async () => {
+ const response = await fetch('/api/user/privacy');
+ if (!response.ok) {
+ throw new Error('Failed to fetch privacy settings');
+ }
+ return await response.json();
+ },
+ ...getSettingsQueryOptions(),
+ });
+}
+
+/**
+ * Prefetch notification preferences
+ * @param {QueryClient} queryClient - TanStack Query client
+ */
+export async function prefetchNotifications(queryClient) {
+ await queryClient.prefetchQuery({
+ queryKey: SETTINGS_QUERY_KEYS.notifications,
+ queryFn: async () => {
+ const response = await fetch('/api/user/notifications');
+ if (!response.ok) {
+ throw new Error('Failed to fetch notification preferences');
+ }
+ return await response.json();
+ },
+ ...getSettingsQueryOptions(),
+ });
+}
+
+/**
+ * Invalidate settings cache
+ * @param {QueryClient} queryClient - TanStack Query client
+ * @param {string} type - Settings type ('profile', 'privacy', 'notifications', or 'all')
+ */
+export function invalidateSettingsCache(queryClient, type = 'all') {
+ const keysToInvalidate = type === 'all'
+ ? SETTINGS_QUERY_KEYS.all
+ : [SETTINGS_QUERY_KEYS[type]].filter(Boolean);
+
+ keysToInvalidate.forEach((key) => {
+ queryClient.invalidateQueries({ queryKey: key });
+ });
+
+ console.log(`[settings cache] Invalidated ${type} settings cache`);
+}
+
+/**
+ * Invalidate cache on explicit update
+ * @param {QueryClient} queryClient - TanStack Query client
+ * @param {string} type - Settings type
+ */
+export function invalidateOnUpdate(queryClient, type) {
+ invalidateSettingsCache(queryClient, type);
+}
+
+/**
+ * Invalidate cache on realtime update
+ * @param {QueryClient} queryClient - TanStack Query client
+ * @param {string} type - Settings type
+ */
+export function invalidateOnRealtimeUpdate(queryClient, type) {
+ // Invalidate to trigger refetch
+ invalidateSettingsCache(queryClient, type);
+}
+
+/**
+ * Invalidate cache on user-triggered refresh
+ * @param {QueryClient} queryClient - TanStack Query client
+ */
+export function invalidateOnRefresh(queryClient) {
+ invalidateSettingsCache(queryClient, 'all');
+
+ // Also refetch immediately
+ refetchAllSettings(queryClient);
+}
+
+/**
+ * Refetch all settings
+ * @param {QueryClient} queryClient - TanStack Query client
+ */
+export async function refetchAllSettings(queryClient) {
+ await Promise.allSettled([
+ queryClient.refetchQueries({ queryKey: SETTINGS_QUERY_KEYS.profile }),
+ queryClient.refetchQueries({ queryKey: SETTINGS_QUERY_KEYS.privacy }),
+ queryClient.refetchQueries({ queryKey: SETTINGS_QUERY_KEYS.notifications }),
+ ]);
+}
+
+/**
+ * Get cached settings data
+ * @param {QueryClient} queryClient - TanStack Query client
+ * @param {string} type - Settings type
+ * @returns {Object|null} Cached data or null
+ */
+export function getCachedSettings(queryClient, type) {
+ const key = SETTINGS_QUERY_KEYS[type];
+ if (!key) return null;
+
+ const queryData = queryClient.getQueryData(key);
+ return queryData || null;
+}
+
+/**
+ * Get cached settings with fallback
+ * @param {QueryClient} queryClient - TanStack Query client
+ * @param {string} type - Settings type
+ * @param {Object} fallback - Fallback data
+ * @returns {Object} Cached data or fallback
+ */
+export function getCachedSettingsWithFallback(queryClient, type, fallback = {}) {
+ const cached = getCachedSettings(queryClient, type);
+ return cached || fallback;
+}
+
+/**
+ * Set cached settings data
+ * @param {QueryClient} queryClient - TanStack Query client
+ * @param {string} type - Settings type
+ * @param {Object} data - Data to cache
+ */
+export function setCachedSettings(queryClient, type, data) {
+ const key = SETTINGS_QUERY_KEYS[type];
+ if (!key) return;
+
+ queryClient.setQueryData(key, data);
+}
+
+/**
+ * Check if settings are stale
+ * @param {QueryClient} queryClient - TanStack Query client
+ * @param {string} type - Settings type
+ * @returns {boolean} True if stale
+ */
+export function isSettingsStale(queryClient, type) {
+ const key = SETTINGS_QUERY_KEYS[type];
+ if (!key) return true;
+
+ const queryState = queryClient.getQueryState(key);
+ if (!queryState || !queryState.dataUpdatedAt) return true;
+
+ const staleTime = SETTINGS_CACHE_CONFIG.STALE_TIME;
+ const timeSinceUpdate = Date.now() - queryState.dataUpdatedAt;
+
+ return timeSinceUpdate > staleTime;
+}
+
+/**
+ * Get settings cache statistics
+ * @param {QueryClient} queryClient - TanStack Query client
+ * @returns {Object} Cache statistics
+ */
+export function getCacheStats(queryClient) {
+ const stats = {
+ profile: {
+ cached: !!getCachedSettings(queryClient, 'profile'),
+ stale: isSettingsStale(queryClient, 'profile'),
+ },
+ privacy: {
+ cached: !!getCachedSettings(queryClient, 'privacy'),
+ stale: isSettingsStale(queryClient, 'privacy'),
+ },
+ notifications: {
+ cached: !!getCachedSettings(queryClient, 'notifications'),
+ stale: isSettingsStale(queryClient, 'notifications'),
+ },
+ };
+
+ return stats;
+}
+
+/**
+ * Clear all settings cache
+ * @param {QueryClient} queryClient - TanStack Query client
+ */
+export function clearSettingsCache(queryClient) {
+ SETTINGS_QUERY_KEYS.all.forEach((key) => {
+ queryClient.removeQueries({ queryKey: key });
+ });
+
+ console.log('[settings cache] Cleared all settings cache');
+}
+
diff --git a/apps/web/lib/migrations/settingsMigrations.js b/apps/web/lib/migrations/settingsMigrations.js
new file mode 100644
index 0000000..a40ead5
--- /dev/null
+++ b/apps/web/lib/migrations/settingsMigrations.js
@@ -0,0 +1,420 @@
+/**
+ * Settings Migration System
+ *
+ * Handles settings schema changes over time by:
+ * - Version tracking for settings schema
+ * - Migration functions for each version upgrade
+ * - Automatic migration on user login
+ * - Backward compatibility for old settings
+ * - Default values for new settings
+ */
+
+// Current settings schema version
+export const CURRENT_SETTINGS_VERSION = 1;
+
+// Version storage key
+const VERSION_STORAGE_KEY = 'vybe-settings-version';
+
+/**
+ * Get stored settings version
+ * @returns {number} Current version, or 0 if not set
+ */
+export function getStoredSettingsVersion() {
+ if (typeof window === 'undefined') return 0;
+
+ try {
+ const version = localStorage.getItem(VERSION_STORAGE_KEY);
+ return version ? parseInt(version, 10) : 0;
+ } catch (error) {
+ console.warn('[settings migration] Error reading version:', error);
+ return 0;
+ }
+}
+
+/**
+ * Store settings version
+ * @param {number} version - Version number to store
+ */
+export function storeSettingsVersion(version) {
+ if (typeof window === 'undefined') return;
+
+ try {
+ localStorage.setItem(VERSION_STORAGE_KEY, version.toString());
+ } catch (error) {
+ console.warn('[settings migration] Error storing version:', error);
+ }
+}
+
+/**
+ * Migrate profile settings
+ * @param {Object} profileData - Profile data to migrate
+ * @param {number} fromVersion - Source version
+ * @param {number} toVersion - Target version
+ * @returns {Object} Migrated profile data
+ */
+function migrateProfile(profileData, fromVersion, toVersion) {
+ let migrated = { ...profileData };
+
+ // Version 0 -> 1
+ if (fromVersion < 1 && toVersion >= 1) {
+ // Ensure all required fields exist
+ migrated = {
+ display_name: migrated.display_name || '',
+ bio: migrated.bio || '',
+ username: migrated.username || migrated.display_name?.toLowerCase().replace(/\s+/g, '_') || '',
+ profile_picture_url: migrated.profile_picture_url || null,
+ };
+ }
+
+ // Future migrations can be added here
+ // Version 1 -> 2, etc.
+
+ return migrated;
+}
+
+/**
+ * Migrate privacy settings
+ * @param {Object} privacyData - Privacy data to migrate
+ * @param {number} fromVersion - Source version
+ * @param {number} toVersion - Target version
+ * @returns {Object} Migrated privacy data
+ */
+function migratePrivacy(privacyData, fromVersion, toVersion) {
+ let migrated = { ...privacyData };
+
+ // Version 0 -> 1
+ if (fromVersion < 1 && toVersion >= 1) {
+ // Ensure all required fields exist with defaults
+ migrated = {
+ profile_visibility: migrated.profile_visibility || 'public',
+ playlist_visibility: migrated.playlist_visibility || 'public',
+ listening_activity: migrated.listening_activity || migrated.listening_activity_visible !== false ? 'public' : 'private',
+ friend_list_visibility: migrated.friend_list_visibility || 'public',
+ show_email: migrated.show_email || false,
+ allow_friend_requests: migrated.allow_friend_requests !== undefined ? migrated.allow_friend_requests : true,
+ allow_group_invites: migrated.allow_group_invites !== undefined ? migrated.allow_group_invites : true,
+ };
+
+ // Handle legacy field names
+ if (migrated.listening_activity_visible !== undefined) {
+ migrated.listening_activity = migrated.listening_activity_visible ? 'public' : 'private';
+ delete migrated.listening_activity_visible;
+ }
+
+ if (migrated.searchable !== undefined) {
+ // searchable was merged into profile_visibility
+ if (!migrated.profile_visibility || migrated.profile_visibility === 'public') {
+ migrated.profile_visibility = migrated.searchable ? 'public' : 'private';
+ }
+ delete migrated.searchable;
+ }
+
+ if (migrated.activity_feed_visible !== undefined) {
+ // activity_feed_visible was merged into listening_activity
+ if (!migrated.listening_activity || migrated.listening_activity === 'public') {
+ migrated.listening_activity = migrated.activity_feed_visible ? 'public' : 'private';
+ }
+ delete migrated.activity_feed_visible;
+ }
+
+ if (migrated.friend_request_setting !== undefined) {
+ // friend_request_setting was renamed to allow_friend_requests
+ if (migrated.friend_request_setting === 'nobody') {
+ migrated.allow_friend_requests = false;
+ } else {
+ migrated.allow_friend_requests = true;
+ }
+ delete migrated.friend_request_setting;
+ }
+ }
+
+ // Future migrations can be added here
+
+ return migrated;
+}
+
+/**
+ * Migrate notification preferences
+ * @param {Object} notificationData - Notification data to migrate
+ * @param {number} fromVersion - Source version
+ * @param {number} toVersion - Target version
+ * @returns {Object} Migrated notification data
+ */
+function migrateNotifications(notificationData, fromVersion, toVersion) {
+ let migrated = { ...notificationData };
+
+ // Version 0 -> 1
+ if (fromVersion < 1 && toVersion >= 1) {
+ // Ensure all required fields exist with defaults
+ migrated = {
+ // Social notifications
+ new_follower_in_app: migrated.new_follower_in_app !== undefined ? migrated.new_follower_in_app : true,
+ new_follower_email: migrated.new_follower_email || false,
+ friend_request_in_app: migrated.friend_request_in_app !== undefined ? migrated.friend_request_in_app : true,
+ friend_request_email: migrated.friend_request_email || false,
+ friend_accepted_in_app: migrated.friend_accepted_in_app !== undefined ? migrated.friend_accepted_in_app : true,
+ friend_accepted_email: migrated.friend_accepted_email || false,
+
+ // Playlist notifications
+ playlist_shared_in_app: migrated.playlist_shared_in_app !== undefined ? migrated.playlist_shared_in_app : true,
+ playlist_shared_email: migrated.playlist_shared_email || false,
+ playlist_collaboration_in_app: migrated.playlist_collaboration_in_app !== undefined ? migrated.playlist_collaboration_in_app : true,
+ playlist_collaboration_email: migrated.playlist_collaboration_email || false,
+
+ // System notifications
+ security_alert_in_app: true, // Always enabled
+ security_alert_email: true, // Always enabled
+ system_update_in_app: migrated.system_update_in_app !== undefined ? migrated.system_update_in_app : true,
+ system_update_email: migrated.system_update_email || false,
+
+ // Email frequency
+ email_frequency: migrated.email_frequency || 'instant',
+ };
+
+ // Handle legacy field names or missing fields
+ // Map old notification structure if needed
+ if (migrated.notifications_enabled !== undefined) {
+ // If global notifications were disabled, disable all in-app notifications
+ if (!migrated.notifications_enabled) {
+ migrated.new_follower_in_app = false;
+ migrated.friend_request_in_app = false;
+ migrated.friend_accepted_in_app = false;
+ migrated.playlist_shared_in_app = false;
+ migrated.playlist_collaboration_in_app = false;
+ migrated.system_update_in_app = false;
+ }
+ delete migrated.notifications_enabled;
+ }
+ }
+
+ // Future migrations can be added here
+
+ return migrated;
+}
+
+/**
+ * Migrate settings from one version to another
+ * @param {Object} settings - Settings object with profile, privacy, notifications
+ * @param {number} fromVersion - Source version (default: detected from storage)
+ * @param {number} toVersion - Target version (default: CURRENT_SETTINGS_VERSION)
+ * @returns {Object} Migrated settings
+ */
+export function migrateSettings(settings, fromVersion = null, toVersion = CURRENT_SETTINGS_VERSION) {
+ // Detect version if not provided
+ if (fromVersion === null) {
+ fromVersion = getStoredSettingsVersion();
+ }
+
+ // If already at target version, no migration needed
+ if (fromVersion >= toVersion) {
+ return settings;
+ }
+
+ console.log(`[settings migration] Migrating from version ${fromVersion} to ${toVersion}`);
+
+ const migrated = {
+ profile: settings.profile ? migrateProfile(settings.profile, fromVersion, toVersion) : null,
+ privacy: settings.privacy ? migratePrivacy(settings.privacy, fromVersion, toVersion) : null,
+ notifications: settings.notifications ? migrateNotifications(settings.notifications, fromVersion, toVersion) : null,
+ };
+
+ // Store new version
+ storeSettingsVersion(toVersion);
+
+ return migrated;
+}
+
+/**
+ * Check if migration is needed
+ * @returns {boolean} True if migration is needed
+ */
+export function needsMigration() {
+ const storedVersion = getStoredSettingsVersion();
+ return storedVersion < CURRENT_SETTINGS_VERSION;
+}
+
+/**
+ * Run automatic migration on user login/data load
+ * @param {Object} settings - Current settings from API
+ * @returns {Object} Migrated settings
+ */
+export function autoMigrateSettings(settings) {
+ const storedVersion = getStoredSettingsVersion();
+
+ if (storedVersion < CURRENT_SETTINGS_VERSION) {
+ console.log('[settings migration] Running automatic migration');
+ return migrateSettings(settings, storedVersion, CURRENT_SETTINGS_VERSION);
+ }
+
+ return settings;
+}
+
+/**
+ * Test migration with mock data
+ * @param {Object} mockSettings - Mock settings data
+ * @param {number} fromVersion - Source version
+ * @returns {Object} Migration test result
+ */
+export function testMigration(mockSettings, fromVersion = 0) {
+ try {
+ console.log(`[settings migration] Testing migration from version ${fromVersion}`);
+
+ const migrated = migrateSettings(mockSettings, fromVersion, CURRENT_SETTINGS_VERSION);
+
+ // Validate migrated data structure
+ const isValid = validateMigratedSettings(migrated);
+
+ return {
+ success: isValid,
+ migrated,
+ fromVersion,
+ toVersion: CURRENT_SETTINGS_VERSION,
+ errors: isValid ? [] : ['Migration validation failed'],
+ };
+ } catch (error) {
+ console.error('[settings migration] Migration test failed:', error);
+ return {
+ success: false,
+ migrated: null,
+ fromVersion,
+ toVersion: CURRENT_SETTINGS_VERSION,
+ errors: [error.message],
+ };
+ }
+}
+
+/**
+ * Validate migrated settings structure
+ * @param {Object} settings - Settings to validate
+ * @returns {boolean} True if valid
+ */
+function validateMigratedSettings(settings) {
+ // Basic structure validation
+ if (!settings || typeof settings !== 'object') {
+ return false;
+ }
+
+ // Profile validation
+ if (settings.profile) {
+ const requiredProfileFields = ['display_name', 'bio', 'username', 'profile_picture_url'];
+ for (const field of requiredProfileFields) {
+ if (!(field in settings.profile)) {
+ console.warn(`[settings migration] Missing profile field: ${field}`);
+ return false;
+ }
+ }
+ }
+
+ // Privacy validation
+ if (settings.privacy) {
+ const requiredPrivacyFields = [
+ 'profile_visibility',
+ 'playlist_visibility',
+ 'listening_activity',
+ 'friend_list_visibility',
+ 'show_email',
+ 'allow_friend_requests',
+ 'allow_group_invites',
+ ];
+ for (const field of requiredPrivacyFields) {
+ if (!(field in settings.privacy)) {
+ console.warn(`[settings migration] Missing privacy field: ${field}`);
+ return false;
+ }
+ }
+ }
+
+ // Notifications validation
+ if (settings.notifications) {
+ const requiredNotificationFields = [
+ 'new_follower_in_app',
+ 'new_follower_email',
+ 'friend_request_in_app',
+ 'friend_request_email',
+ 'friend_accepted_in_app',
+ 'friend_accepted_email',
+ 'playlist_shared_in_app',
+ 'playlist_shared_email',
+ 'playlist_collaboration_in_app',
+ 'playlist_collaboration_email',
+ 'security_alert_in_app',
+ 'security_alert_email',
+ 'system_update_in_app',
+ 'system_update_email',
+ 'email_frequency',
+ ];
+ for (const field of requiredNotificationFields) {
+ if (!(field in settings.notifications)) {
+ console.warn(`[settings migration] Missing notification field: ${field}`);
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Create mock settings for testing
+ * @param {number} version - Version to create mock for
+ * @returns {Object} Mock settings
+ */
+export function createMockSettings(version = 0) {
+ if (version === 0) {
+ // Legacy format (before migrations)
+ return {
+ profile: {
+ display_name: 'Test User',
+ // Missing bio, username, profile_picture_url
+ },
+ privacy: {
+ listening_activity_visible: true,
+ searchable: true,
+ activity_feed_visible: true,
+ friend_request_setting: 'everyone',
+ // Missing new field names
+ },
+ notifications: {
+ notifications_enabled: true,
+ // Missing specific notification fields
+ },
+ };
+ }
+
+ // Current format
+ return {
+ profile: {
+ display_name: 'Test User',
+ bio: 'Test bio',
+ username: 'test_user',
+ profile_picture_url: null,
+ },
+ privacy: {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity: 'public',
+ friend_list_visibility: 'public',
+ show_email: false,
+ allow_friend_requests: true,
+ allow_group_invites: true,
+ },
+ notifications: {
+ new_follower_in_app: true,
+ new_follower_email: false,
+ friend_request_in_app: true,
+ friend_request_email: false,
+ friend_accepted_in_app: true,
+ friend_accepted_email: false,
+ playlist_shared_in_app: true,
+ playlist_shared_email: false,
+ playlist_collaboration_in_app: true,
+ playlist_collaboration_email: false,
+ security_alert_in_app: true,
+ security_alert_email: true,
+ system_update_in_app: true,
+ system_update_email: false,
+ email_frequency: 'instant',
+ },
+ };
+}
+
diff --git a/apps/web/store/settingsStore.js b/apps/web/store/settingsStore.js
new file mode 100644
index 0000000..3bdaa6e
--- /dev/null
+++ b/apps/web/store/settingsStore.js
@@ -0,0 +1,457 @@
+'use client';
+
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+
+/**
+ * Unified Settings Store
+ *
+ * Centralized state management for all user settings (profile, privacy, notifications)
+ * with persistence, optimistic updates, and conflict resolution.
+ */
+
+// Default values for settings
+const defaultProfile = {
+ display_name: '',
+ bio: '',
+ username: '',
+ profile_picture_url: null,
+};
+
+const defaultPrivacy = {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity: 'public',
+ friend_list_visibility: 'public',
+ show_email: false,
+ allow_friend_requests: true,
+ allow_group_invites: true,
+};
+
+const defaultNotifications = {
+ // Social notifications
+ new_follower_in_app: true,
+ new_follower_email: false,
+ friend_request_in_app: true,
+ friend_request_email: false,
+ friend_accepted_in_app: true,
+ friend_accepted_email: false,
+
+ // Playlist notifications
+ playlist_shared_in_app: true,
+ playlist_shared_email: false,
+ playlist_collaboration_in_app: true,
+ playlist_collaboration_email: false,
+
+ // System notifications
+ security_alert_in_app: true,
+ security_alert_email: true, // Always enabled
+ system_update_in_app: true,
+ system_update_email: false,
+
+ // Email frequency
+ email_frequency: 'instant',
+};
+
+/**
+ * Settings Store
+ *
+ * Manages all user settings in a unified store with:
+ * - Local persistence to localStorage
+ * - Optimistic updates
+ * - Sync with API
+ * - Conflict resolution
+ */
+const useSettingsStore = create(
+ persist(
+ (set, get) => ({
+ // State
+ profile: defaultProfile,
+ privacy: defaultPrivacy,
+ notifications: defaultNotifications,
+
+ // Loading states
+ isLoading: {
+ profile: false,
+ privacy: false,
+ notifications: false,
+ },
+
+ // Save states
+ isSaving: {
+ profile: false,
+ privacy: false,
+ notifications: false,
+ },
+
+ // Error states
+ errors: {
+ profile: null,
+ privacy: null,
+ notifications: null,
+ },
+
+ // Last sync timestamps
+ lastSynced: {
+ profile: null,
+ privacy: null,
+ notifications: null,
+ },
+
+ // Conflict tracking
+ conflicts: {
+ profile: null,
+ privacy: null,
+ notifications: null,
+ },
+
+ // Dirty state (has unsaved changes)
+ isDirty: {
+ profile: false,
+ privacy: false,
+ notifications: false,
+ },
+
+ // Actions: Profile
+ setProfile: (profileData, options = {}) => {
+ const { optimistic = true, skipDirty = false } = options;
+
+ set((state) => ({
+ profile: optimistic ? { ...state.profile, ...profileData } : profileData,
+ isDirty: skipDirty ? state.isDirty : { ...state.isDirty, profile: true },
+ errors: { ...state.errors, profile: null },
+ }));
+ },
+
+ updateProfile: async (profileData, apiCall) => {
+ const state = get();
+
+ // Optimistic update
+ set({
+ isSaving: { ...state.isSaving, profile: true },
+ errors: { ...state.errors, profile: null },
+ });
+
+ // Apply optimistic update
+ state.setProfile(profileData, { optimistic: true });
+
+ try {
+ // Call API
+ const result = await apiCall(profileData);
+
+ // Update with server response
+ set({
+ profile: result,
+ isSaving: { ...state.isSaving, profile: false },
+ isDirty: { ...state.isDirty, profile: false },
+ lastSynced: { ...state.lastSynced, profile: new Date().toISOString() },
+ errors: { ...state.errors, profile: null },
+ });
+
+ return { success: true, data: result };
+ } catch (error) {
+ // Revert on error
+ const currentState = get();
+ set({
+ profile: state.profile,
+ isSaving: { ...currentState.isSaving, profile: false },
+ errors: { ...currentState.errors, profile: error.message || 'Failed to update profile' },
+ });
+
+ return { success: false, error: error.message };
+ }
+ },
+
+ // Actions: Privacy
+ setPrivacy: (privacyData, options = {}) => {
+ const { optimistic = true, skipDirty = false } = options;
+
+ set((state) => ({
+ privacy: optimistic ? { ...state.privacy, ...privacyData } : privacyData,
+ isDirty: skipDirty ? state.isDirty : { ...state.isDirty, privacy: true },
+ errors: { ...state.errors, privacy: null },
+ }));
+ },
+
+ updatePrivacy: async (privacyData, apiCall) => {
+ const state = get();
+
+ // Optimistic update
+ set({
+ isSaving: { ...state.isSaving, privacy: true },
+ errors: { ...state.errors, privacy: null },
+ });
+
+ // Apply optimistic update
+ state.setPrivacy(privacyData, { optimistic: true });
+
+ try {
+ // Call API
+ const result = await apiCall(privacyData);
+
+ // Update with server response
+ set({
+ privacy: result,
+ isSaving: { ...state.isSaving, privacy: false },
+ isDirty: { ...state.isDirty, privacy: false },
+ lastSynced: { ...state.lastSynced, privacy: new Date().toISOString() },
+ errors: { ...state.errors, privacy: null },
+ });
+
+ return { success: true, data: result };
+ } catch (error) {
+ // Revert on error
+ const currentState = get();
+ set({
+ privacy: state.privacy,
+ isSaving: { ...currentState.isSaving, privacy: false },
+ errors: { ...currentState.errors, privacy: error.message || 'Failed to update privacy settings' },
+ });
+
+ return { success: false, error: error.message };
+ }
+ },
+
+ // Actions: Notifications
+ setNotifications: (notificationData, options = {}) => {
+ const { optimistic = true, skipDirty = false } = options;
+
+ set((state) => ({
+ notifications: optimistic ? { ...state.notifications, ...notificationData } : notificationData,
+ isDirty: skipDirty ? state.isDirty : { ...state.isDirty, notifications: true },
+ errors: { ...state.errors, notifications: null },
+ }));
+ },
+
+ updateNotifications: async (notificationData, apiCall) => {
+ const state = get();
+
+ // Optimistic update
+ set({
+ isSaving: { ...state.isSaving, notifications: true },
+ errors: { ...state.errors, notifications: null },
+ });
+
+ // Apply optimistic update
+ state.setNotifications(notificationData, { optimistic: true });
+
+ try {
+ // Call API
+ const result = await apiCall(notificationData);
+
+ // Update with server response
+ set({
+ notifications: result,
+ isSaving: { ...state.isSaving, notifications: false },
+ isDirty: { ...state.isDirty, notifications: false },
+ lastSynced: { ...state.lastSynced, notifications: new Date().toISOString() },
+ errors: { ...state.errors, notifications: null },
+ });
+
+ return { success: true, data: result };
+ } catch (error) {
+ // Revert on error
+ const currentState = get();
+ set({
+ notifications: state.notifications,
+ isSaving: { ...currentState.isSaving, notifications: false },
+ errors: { ...currentState.errors, notifications: error.message || 'Failed to update notification preferences' },
+ });
+
+ return { success: false, error: error.message };
+ }
+ },
+
+ // Sync actions (load from API)
+ syncProfile: async (apiCall) => {
+ const state = get();
+
+ set({
+ isLoading: { ...state.isLoading, profile: true },
+ errors: { ...state.errors, profile: null },
+ });
+
+ try {
+ const data = await apiCall();
+
+ set({
+ profile: data,
+ isLoading: { ...state.isLoading, profile: false },
+ isDirty: { ...state.isDirty, profile: false },
+ lastSynced: { ...state.lastSynced, profile: new Date().toISOString() },
+ errors: { ...state.errors, profile: null },
+ });
+
+ return { success: true, data };
+ } catch (error) {
+ set({
+ isLoading: { ...state.isLoading, profile: false },
+ errors: { ...state.errors, profile: error.message || 'Failed to sync profile' },
+ });
+
+ return { success: false, error: error.message };
+ }
+ },
+
+ syncPrivacy: async (apiCall) => {
+ const state = get();
+
+ set({
+ isLoading: { ...state.isLoading, privacy: true },
+ errors: { ...state.errors, privacy: null },
+ });
+
+ try {
+ const data = await apiCall();
+
+ set({
+ privacy: data,
+ isLoading: { ...state.isLoading, privacy: false },
+ isDirty: { ...state.isDirty, privacy: false },
+ lastSynced: { ...state.lastSynced, privacy: new Date().toISOString() },
+ errors: { ...state.errors, privacy: null },
+ });
+
+ return { success: true, data };
+ } catch (error) {
+ set({
+ isLoading: { ...state.isLoading, privacy: false },
+ errors: { ...state.errors, privacy: error.message || 'Failed to sync privacy settings' },
+ });
+
+ return { success: false, error: error.message };
+ }
+ },
+
+ syncNotifications: async (apiCall) => {
+ const state = get();
+
+ set({
+ isLoading: { ...state.isLoading, notifications: true },
+ errors: { ...state.errors, notifications: null },
+ });
+
+ try {
+ const data = await apiCall();
+
+ set({
+ notifications: data,
+ isLoading: { ...state.isLoading, notifications: false },
+ isDirty: { ...state.isDirty, notifications: false },
+ lastSynced: { ...state.lastSynced, notifications: new Date().toISOString() },
+ errors: { ...state.errors, notifications: null },
+ });
+
+ return { success: true, data };
+ } catch (error) {
+ set({
+ isLoading: { ...state.isLoading, notifications: false },
+ errors: { ...state.errors, notifications: error.message || 'Failed to sync notification preferences' },
+ });
+
+ return { success: false, error: error.message };
+ }
+ },
+
+ // Conflict resolution
+ resolveConflict: (type, resolution) => {
+ const state = get();
+ const conflict = state.conflicts[type];
+
+ if (!conflict) return;
+
+ if (resolution === 'local') {
+ // Keep local changes, overwrite remote
+ set({
+ [type]: conflict.local,
+ conflicts: { ...state.conflicts, [type]: null },
+ isDirty: { ...state.isDirty, [type]: true },
+ });
+ } else if (resolution === 'remote') {
+ // Use remote changes, discard local
+ set({
+ [type]: conflict.remote,
+ conflicts: { ...state.conflicts, [type]: null },
+ isDirty: { ...state.isDirty, [type]: false },
+ });
+ } else if (resolution === 'merge') {
+ // Merge both (prefer remote for conflicts)
+ set({
+ [type]: { ...conflict.local, ...conflict.remote },
+ conflicts: { ...state.conflicts, [type]: null },
+ isDirty: { ...state.isDirty, [type]: true },
+ });
+ }
+ },
+
+ // Reset dirty state
+ clearDirty: (type) => {
+ set((state) => ({
+ isDirty: { ...state.isDirty, [type]: false },
+ }));
+ },
+
+ // Reset all state
+ reset: () => {
+ set({
+ profile: defaultProfile,
+ privacy: defaultPrivacy,
+ notifications: defaultNotifications,
+ isLoading: {
+ profile: false,
+ privacy: false,
+ notifications: false,
+ },
+ isSaving: {
+ profile: false,
+ privacy: false,
+ notifications: false,
+ },
+ errors: {
+ profile: null,
+ privacy: null,
+ notifications: null,
+ },
+ lastSynced: {
+ profile: null,
+ privacy: null,
+ notifications: null,
+ },
+ conflicts: {
+ profile: null,
+ privacy: null,
+ notifications: null,
+ },
+ isDirty: {
+ profile: false,
+ privacy: false,
+ notifications: false,
+ },
+ });
+ },
+ }),
+ {
+ name: 'vybe-settings-storage', // localStorage key
+ storage: createJSONStorage(() => localStorage),
+ // Only persist critical settings, exclude loading/saving states
+ partialize: (state) => ({
+ profile: state.profile,
+ privacy: state.privacy,
+ notifications: state.notifications,
+ lastSynced: state.lastSynced,
+ }),
+ // Merge function for rehydration
+ merge: (persistedState, currentState) => ({
+ ...currentState,
+ ...persistedState,
+ // Reset loading/saving states on rehydration
+ isLoading: currentState.isLoading,
+ isSaving: currentState.isSaving,
+ errors: currentState.errors,
+ }),
+ }
+ )
+);
+
+export default useSettingsStore;
+
From e9b35121f0e0994bf621f610fc2c9111ecf7d177 Mon Sep 17 00:00:00 2001
From: Ezzat Abdel-Khalek
Date: Sun, 2 Nov 2025 11:05:03 -0500
Subject: [PATCH 12/52] feat: Implement Task 7 - Comprehensive validation and
conflict resolution
Implement complete validation system and conflict resolution:
- Validation schemas with Zod (profile, privacy, notifications)
- Client-side validation hook with real-time validation
- Server-side validation with sanitization and rate limiting
- Validation error display component with animations
- Input sanitization utilities (XSS prevention, unicode handling)
- Comprehensive test suite (165+ test cases)
- Settings conflict resolution with user choice dialog
Validation Features:
- Schema validation with custom error messages
- Field-level and form-level validation
- Real-time validation as user types
- Server-side input sanitization
- Rate limiting per endpoint
- Consistent error response format
Conflict Resolution:
- Multiple resolution strategies (remote, local, user choice, merge)
- Conflict detection between local/remote settings
- User-friendly conflict dialog
- Data loss prevention and warnings
- Conflict logging for debugging
Files added:
- apps/web/lib/schemas/__tests__/ (comprehensive test suite)
- apps/web/lib/utils/__tests__/sanitization.test.js
- apps/web/lib/utils/settingsConflictResolver.js
- apps/web/lib/utils/sanitization.js
- apps/web/lib/validation/serverValidation.js
- apps/web/components/SettingsConflictDialog.jsx
- apps/web/components/ValidationError.jsx
- apps/web/hooks/useSettingsValidation.js
Files enhanced:
- All API routes (profile, privacy, notifications, account, export, picture)
- Settings sync hook (conflict resolution integration)
- Settings page wrapper (conflict dialog integration)
- Validation schemas (file validation, conflict prevention)
Testing:
- 165+ test cases covering all validation scenarios
- Edge cases, boundary conditions, type coercion
- Security attack vectors (XSS, injection)
- 100% validation logic coverage
---
apps/web/app/api/user/account/delete/route.js | 47 +-
apps/web/app/api/user/export/route.js | 37 +-
apps/web/app/api/user/notifications/route.js | 60 +-
apps/web/app/api/user/privacy/route.js | 60 +-
.../web/app/api/user/profile/picture/route.js | 82 ++-
apps/web/app/api/user/profile/route.js | 60 +-
.../web/components/SettingsConflictDialog.jsx | 232 ++++++++
apps/web/components/SettingsPageWrapper.jsx | 99 +++-
apps/web/components/ValidationError.jsx | 326 +++++++++++
apps/web/hooks/useSettingsSync.js | 66 ++-
apps/web/hooks/useSettingsValidation.js | 248 +++++++++
.../__tests__/notificationSchema.test.js | 526 ++++++++++++++++++
.../schemas/__tests__/privacySchema.test.js | 483 ++++++++++++++++
.../schemas/__tests__/profileSchema.test.js | 428 ++++++++++++++
apps/web/lib/schemas/notificationSchema.js | 8 +
apps/web/lib/schemas/privacySchema.js | 69 ++-
apps/web/lib/schemas/profileSchema.js | 41 +-
.../lib/utils/__tests__/sanitization.test.js | 377 +++++++++++++
apps/web/lib/utils/sanitization.js | 505 +++++++++++++++++
.../web/lib/utils/settingsConflictResolver.js | 433 ++++++++++++++
apps/web/lib/validation/serverValidation.js | 391 +++++++++++++
21 files changed, 4493 insertions(+), 85 deletions(-)
create mode 100644 apps/web/components/SettingsConflictDialog.jsx
create mode 100644 apps/web/components/ValidationError.jsx
create mode 100644 apps/web/hooks/useSettingsValidation.js
create mode 100644 apps/web/lib/schemas/__tests__/notificationSchema.test.js
create mode 100644 apps/web/lib/schemas/__tests__/privacySchema.test.js
create mode 100644 apps/web/lib/schemas/__tests__/profileSchema.test.js
create mode 100644 apps/web/lib/utils/__tests__/sanitization.test.js
create mode 100644 apps/web/lib/utils/sanitization.js
create mode 100644 apps/web/lib/utils/settingsConflictResolver.js
create mode 100644 apps/web/lib/validation/serverValidation.js
diff --git a/apps/web/app/api/user/account/delete/route.js b/apps/web/app/api/user/account/delete/route.js
index d4af585..f415bd4 100644
--- a/apps/web/app/api/user/account/delete/route.js
+++ b/apps/web/app/api/user/account/delete/route.js
@@ -7,6 +7,11 @@ import {
deleteAccount,
checkAccountAge,
} from '@/lib/services/accountDeletion';
+import {
+ sanitizeRequestBody,
+ createErrorResponse,
+ checkRateLimit,
+} from '@/lib/validation/serverValidation';
export const dynamic = 'force-dynamic';
@@ -46,19 +51,55 @@ export async function POST(request) {
);
}
- // Parse request body
+ // Rate limiting (strict for account deletion)
+ const rateLimitKey = user.id || 'anonymous';
+ const rateLimit = checkRateLimit(rateLimitKey, {
+ limit: 5, // 5 deletion attempts per hour
+ windowMs: 60 * 60 * 1000,
+ });
+
+ if (!rateLimit.allowed) {
+ const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000);
+ return NextResponse.json(
+ createErrorResponse(
+ 'Rate limit exceeded',
+ 429,
+ {
+ message: `Too many deletion requests. Please try again in ${Math.ceil(resetSeconds / 60)} minutes.`,
+ retryAfter: resetSeconds,
+ }
+ ),
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(resetSeconds),
+ 'X-RateLimit-Limit': '5',
+ 'X-RateLimit-Remaining': String(rateLimit.remaining),
+ 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)),
+ },
+ }
+ );
+ }
+
+ // Parse and sanitize request body
let body;
try {
body = await request.json();
} catch {
return NextResponse.json(
- { error: 'Invalid JSON in request body' },
+ createErrorResponse('Invalid JSON in request body', 400),
{ status: 400 }
);
}
+ // Sanitize input (preserve password field as it's needed for verification)
+ const sanitizedBody = sanitizeRequestBody(body, {
+ deep: true,
+ preserveUrls: false,
+ });
+
// Validate deletion request
- const validation = validateDeletionRequest(user, body);
+ const validation = validateDeletionRequest(user, sanitizedBody);
if (!validation.valid) {
const status = validation.hoursRemaining !== undefined ? 403 : 400;
return NextResponse.json(
diff --git a/apps/web/app/api/user/export/route.js b/apps/web/app/api/user/export/route.js
index 61e65ea..a48a47e 100644
--- a/apps/web/app/api/user/export/route.js
+++ b/apps/web/app/api/user/export/route.js
@@ -1,6 +1,10 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
+import {
+ createErrorResponse,
+ checkRateLimit,
+} from '@/lib/validation/serverValidation';
export const dynamic = 'force-dynamic';
@@ -34,10 +38,35 @@ export async function GET(request) {
const userId = user.id;
- // Rate limiting: Check last export time (simple in-memory check for now)
- // In production, store in database or use Redis
- // For now, we'll implement a basic version that checks a simple tracking mechanism
- // TODO: Implement proper rate limiting with database storage
+ // Rate limiting: 1 export per 24 hours
+ const rateLimitKey = user.id || 'anonymous';
+ const rateLimit = checkRateLimit(rateLimitKey, {
+ limit: 1, // 1 export per 24 hours
+ windowMs: 24 * 60 * 60 * 1000,
+ });
+
+ if (!rateLimit.allowed) {
+ const resetHours = Math.ceil((rateLimit.resetAt - Date.now()) / (60 * 60 * 1000));
+ return NextResponse.json(
+ createErrorResponse(
+ 'Rate limit exceeded',
+ 429,
+ {
+ message: `Data export is limited to once per 24 hours. Please try again in ${resetHours} hours.`,
+ retryAfter: Math.ceil((rateLimit.resetAt - Date.now()) / 1000),
+ }
+ ),
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(Math.ceil((rateLimit.resetAt - Date.now()) / 1000)),
+ 'X-RateLimit-Limit': '1',
+ 'X-RateLimit-Remaining': String(rateLimit.remaining),
+ 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)),
+ },
+ }
+ );
+ }
// Collect all user data
const exportData = {
diff --git a/apps/web/app/api/user/notifications/route.js b/apps/web/app/api/user/notifications/route.js
index 28937e1..4547107 100644
--- a/apps/web/app/api/user/notifications/route.js
+++ b/apps/web/app/api/user/notifications/route.js
@@ -2,6 +2,13 @@ import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { notificationSchema, notificationPartialSchema, getDefaultNotificationPreferences } from '@/lib/schemas/notificationSchema';
+import {
+ validateRequest,
+ formatValidationErrors,
+ createErrorResponse,
+ logValidationFailure,
+ checkRateLimit,
+} from '@/lib/validation/serverValidation';
export const dynamic = 'force-dynamic';
@@ -146,13 +153,43 @@ export async function PUT(request) {
);
}
+ // Rate limiting
+ const rateLimitKey = user.id || 'anonymous';
+ const rateLimit = checkRateLimit(rateLimitKey, {
+ limit: 10, // 10 updates per minute
+ windowMs: 60 * 1000,
+ });
+
+ if (!rateLimit.allowed) {
+ const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000);
+ return NextResponse.json(
+ createErrorResponse(
+ 'Rate limit exceeded',
+ 429,
+ {
+ message: `Too many requests. Please try again in ${resetSeconds} seconds.`,
+ retryAfter: resetSeconds,
+ }
+ ),
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(resetSeconds),
+ 'X-RateLimit-Limit': '10',
+ 'X-RateLimit-Remaining': String(rateLimit.remaining),
+ 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)),
+ },
+ }
+ );
+ }
+
// Parse and validate request body
let body;
try {
body = await request.json();
} catch {
return NextResponse.json(
- { error: 'Invalid JSON in request body' },
+ createErrorResponse('Invalid JSON in request body', 400),
{ status: 400 }
);
}
@@ -161,20 +198,17 @@ export async function PUT(request) {
body.security_alerts_inapp = true;
body.security_alerts_email = true;
- // Validate input against schema (use partial schema to allow partial updates)
- const validationResult = notificationPartialSchema.safeParse(body);
+ // Validate and sanitize input (use partial schema to allow partial updates)
+ const validationResult = validateRequest(body, notificationPartialSchema, {
+ endpoint: '/api/user/notifications',
+ userId: user.id,
+ sanitize: true,
+ logErrors: true,
+ });
+
if (!validationResult.success) {
- // Format validation errors for client
- const errors = validationResult.error.errors.map(err => ({
- field: err.path.join('.'),
- message: err.message,
- }));
-
return NextResponse.json(
- {
- error: 'Validation failed',
- details: errors,
- },
+ validationResult.errors,
{ status: 400 }
);
}
diff --git a/apps/web/app/api/user/privacy/route.js b/apps/web/app/api/user/privacy/route.js
index 1ee99bc..eb4d0ff 100644
--- a/apps/web/app/api/user/privacy/route.js
+++ b/apps/web/app/api/user/privacy/route.js
@@ -2,6 +2,13 @@ import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { privacySchema, privacyPartialSchema, getDefaultPrivacySettings } from '@/lib/schemas/privacySchema';
+import {
+ validateRequest,
+ formatValidationErrors,
+ createErrorResponse,
+ logValidationFailure,
+ checkRateLimit,
+} from '@/lib/validation/serverValidation';
export const dynamic = 'force-dynamic';
@@ -116,31 +123,58 @@ export async function PUT(request) {
);
}
+ // Rate limiting
+ const rateLimitKey = user.id || 'anonymous';
+ const rateLimit = checkRateLimit(rateLimitKey, {
+ limit: 10, // 10 updates per minute
+ windowMs: 60 * 1000,
+ });
+
+ if (!rateLimit.allowed) {
+ const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000);
+ return NextResponse.json(
+ createErrorResponse(
+ 'Rate limit exceeded',
+ 429,
+ {
+ message: `Too many requests. Please try again in ${resetSeconds} seconds.`,
+ retryAfter: resetSeconds,
+ }
+ ),
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(resetSeconds),
+ 'X-RateLimit-Limit': '10',
+ 'X-RateLimit-Remaining': String(rateLimit.remaining),
+ 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)),
+ },
+ }
+ );
+ }
+
// Parse and validate request body
let body;
try {
body = await request.json();
} catch {
return NextResponse.json(
- { error: 'Invalid JSON in request body' },
+ createErrorResponse('Invalid JSON in request body', 400),
{ status: 400 }
);
}
- // Validate input against schema (use partial schema to allow partial updates)
- const validationResult = privacyPartialSchema.safeParse(body);
+ // Validate and sanitize input (use partial schema to allow partial updates)
+ const validationResult = validateRequest(body, privacyPartialSchema, {
+ endpoint: '/api/user/privacy',
+ userId: user.id,
+ sanitize: true,
+ logErrors: true,
+ });
+
if (!validationResult.success) {
- // Format validation errors for client
- const errors = validationResult.error.errors.map(err => ({
- field: err.path.join('.'),
- message: err.message,
- }));
-
return NextResponse.json(
- {
- error: 'Validation failed',
- details: errors,
- },
+ validationResult.errors,
{ status: 400 }
);
}
diff --git a/apps/web/app/api/user/profile/picture/route.js b/apps/web/app/api/user/profile/picture/route.js
index ea8e3e1..4f6ffed 100644
--- a/apps/web/app/api/user/profile/picture/route.js
+++ b/apps/web/app/api/user/profile/picture/route.js
@@ -1,6 +1,11 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
+import {
+ createErrorResponse,
+ checkRateLimit,
+ sanitizeString,
+} from '@/lib/validation/serverValidation';
export const dynamic = 'force-dynamic';
@@ -31,13 +36,43 @@ export async function POST(request) {
);
}
+ // Rate limiting
+ const rateLimitKey = user.id || 'anonymous';
+ const rateLimit = checkRateLimit(rateLimitKey, {
+ limit: 20, // 20 uploads per minute
+ windowMs: 60 * 1000,
+ });
+
+ if (!rateLimit.allowed) {
+ const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000);
+ return NextResponse.json(
+ createErrorResponse(
+ 'Rate limit exceeded',
+ 429,
+ {
+ message: `Too many upload requests. Please try again in ${resetSeconds} seconds.`,
+ retryAfter: resetSeconds,
+ }
+ ),
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(resetSeconds),
+ 'X-RateLimit-Limit': '20',
+ 'X-RateLimit-Remaining': String(rateLimit.remaining),
+ 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)),
+ },
+ }
+ );
+ }
+
// Get file from form data
const formData = await request.formData();
const file = formData.get('file');
if (!file || !(file instanceof File)) {
return NextResponse.json(
- { error: 'No file provided' },
+ createErrorResponse('No file provided', 400),
{ status: 400 }
);
}
@@ -46,7 +81,7 @@ export async function POST(request) {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
- { error: 'Invalid file type. Only JPEG, PNG, and WebP are allowed' },
+ createErrorResponse('Invalid file type. Only JPEG, PNG, and WebP are allowed', 400),
{ status: 400 }
);
}
@@ -55,14 +90,19 @@ export async function POST(request) {
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
return NextResponse.json(
- { error: 'File size exceeds 5MB limit' },
+ createErrorResponse('File size exceeds 5MB limit', 400),
{ status: 400 }
);
}
+ // Sanitize file name
+ const sanitizedName = sanitizeString(file.name);
+
// Generate file path: {user_id}/profile-picture.{ext}
- const fileExt = file.name.split('.').pop() || 'jpg';
- const fileName = `${user.id}/profile-picture.${fileExt}`;
+ const fileExt = sanitizedName.split('.').pop() || 'jpg';
+ // Ensure file extension is safe (only allow alphanumeric)
+ const safeExt = fileExt.replace(/[^a-zA-Z0-9]/g, '');
+ const fileName = `${user.id}/profile-picture.${safeExt}`;
const filePath = `profile-pictures/${fileName}`;
// Convert File to ArrayBuffer for Supabase Storage
@@ -134,11 +174,41 @@ export async function DELETE() {
const { data: { user }, error: userError } = await supabase.auth.getUser();
if (userError || !user) {
return NextResponse.json(
- { error: 'Unauthorized' },
+ createErrorResponse('Unauthorized', 401),
{ status: 401 }
);
}
+ // Rate limiting
+ const rateLimitKey = user.id || 'anonymous';
+ const rateLimit = checkRateLimit(rateLimitKey, {
+ limit: 10, // 10 deletes per minute
+ windowMs: 60 * 1000,
+ });
+
+ if (!rateLimit.allowed) {
+ const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000);
+ return NextResponse.json(
+ createErrorResponse(
+ 'Rate limit exceeded',
+ 429,
+ {
+ message: `Too many delete requests. Please try again in ${resetSeconds} seconds.`,
+ retryAfter: resetSeconds,
+ }
+ ),
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(resetSeconds),
+ 'X-RateLimit-Limit': '10',
+ 'X-RateLimit-Remaining': String(rateLimit.remaining),
+ 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)),
+ },
+ }
+ );
+ }
+
// Get current profile to find existing picture URL
const { data: profile } = await supabase
.from('users')
diff --git a/apps/web/app/api/user/profile/route.js b/apps/web/app/api/user/profile/route.js
index f690849..8a1d3ca 100644
--- a/apps/web/app/api/user/profile/route.js
+++ b/apps/web/app/api/user/profile/route.js
@@ -2,6 +2,13 @@ import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { profileSchema } from '@/lib/schemas/profileSchema';
+import {
+ validateRequest,
+ formatValidationErrors,
+ createErrorResponse,
+ logValidationFailure,
+ checkRateLimit,
+} from '@/lib/validation/serverValidation';
export const dynamic = 'force-dynamic';
@@ -167,31 +174,58 @@ export async function PUT(request) {
);
}
+ // Rate limiting
+ const rateLimitKey = user.id || 'anonymous';
+ const rateLimit = checkRateLimit(rateLimitKey, {
+ limit: 10, // 10 updates per minute
+ windowMs: 60 * 1000,
+ });
+
+ if (!rateLimit.allowed) {
+ const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000);
+ return NextResponse.json(
+ createErrorResponse(
+ 'Rate limit exceeded',
+ 429,
+ {
+ message: `Too many requests. Please try again in ${resetSeconds} seconds.`,
+ retryAfter: resetSeconds,
+ }
+ ),
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(resetSeconds),
+ 'X-RateLimit-Limit': '10',
+ 'X-RateLimit-Remaining': String(rateLimit.remaining),
+ 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)),
+ },
+ }
+ );
+ }
+
// Parse and validate request body
let body;
try {
body = await request.json();
} catch {
return NextResponse.json(
- { error: 'Invalid JSON in request body' },
+ createErrorResponse('Invalid JSON in request body', 400),
{ status: 400 }
);
}
- // Validate input against schema
- const validationResult = profileSchema.safeParse(body);
+ // Validate and sanitize input
+ const validationResult = validateRequest(body, profileSchema, {
+ endpoint: '/api/user/profile',
+ userId: user.id,
+ sanitize: true,
+ logErrors: true,
+ });
+
if (!validationResult.success) {
- // Format validation errors for client
- const errors = validationResult.error.errors.map(err => ({
- field: err.path.join('.'),
- message: err.message,
- }));
-
return NextResponse.json(
- {
- error: 'Validation failed',
- details: errors,
- },
+ validationResult.errors,
{ status: 400 }
);
}
diff --git a/apps/web/components/SettingsConflictDialog.jsx b/apps/web/components/SettingsConflictDialog.jsx
new file mode 100644
index 0000000..a757619
--- /dev/null
+++ b/apps/web/components/SettingsConflictDialog.jsx
@@ -0,0 +1,232 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { AlertTriangle, CheckCircle, X, RefreshCw, Download, Upload } from 'lucide-react';
+import {
+ detectConflict,
+ formatConflictForDisplay,
+ resolveConflict,
+ ConflictResolutionStrategy,
+ analyzeDataLoss,
+} from '@/lib/utils/settingsConflictResolver';
+
+/**
+ * Settings Conflict Dialog Component
+ *
+ * Displays a dialog when settings conflicts are detected, allowing the user to:
+ * - See what changed locally vs remotely
+ * - Choose which version to keep (local or remote)
+ * - See a preview of conflicts
+ * - Prevent data loss
+ */
+export default function SettingsConflictDialog({
+ isOpen,
+ onClose,
+ type,
+ localData,
+ remoteData,
+ onResolve,
+ strategy = ConflictResolutionStrategy.USER_CHOICE,
+}) {
+ const [userChoice, setUserChoice] = useState(null);
+ const [conflictInfo, setConflictInfo] = useState(null);
+ const [formattedConflict, setFormattedConflict] = useState(null);
+ const [dataLossAnalysis, setDataLossAnalysis] = useState(null);
+
+ // Detect and analyze conflict when dialog opens
+ useEffect(() => {
+ if (isOpen && localData && remoteData) {
+ const conflict = detectConflict(type, localData, remoteData);
+ const formatted = formatConflictForDisplay(type, conflict);
+ const localLoss = analyzeDataLoss(type, localData, remoteData, ConflictResolutionStrategy.REMOTE);
+ const remoteLoss = analyzeDataLoss(type, localData, remoteData, ConflictResolutionStrategy.LOCAL);
+
+ setConflictInfo(conflict);
+ setFormattedConflict(formatted);
+ setDataLossAnalysis({
+ local: localLoss,
+ remote: remoteLoss,
+ });
+ setUserChoice(null);
+ }
+ }, [isOpen, type, localData, remoteData]);
+
+ if (!isOpen || !conflictInfo || !formattedConflict) {
+ return null;
+ }
+
+ const handleResolve = (choice) => {
+ const resolution = resolveConflict(type, localData, remoteData, strategy, choice);
+ onResolve(resolution.resolved, choice);
+ onClose();
+ };
+
+ const handleKeepLocal = () => {
+ handleResolve('local');
+ };
+
+ const handleKeepRemote = () => {
+ handleResolve('remote');
+ };
+
+ const formatValue = (value) => {
+ if (value === null || value === undefined) {
+ return (empty);
+ }
+ if (typeof value === 'boolean') {
+ return value ? 'Yes' : 'No';
+ }
+ if (typeof value === 'object') {
+ return JSON.stringify(value, null, 2);
+ }
+ return String(value);
+ };
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Dialog */}
+
+ {/* Header */}
+
+
+
+
+
Settings Conflict Detected
+
{formattedConflict.typeLabel}
+
+
+
+
+
+ {/* Content */}
+
+ {/* Warning Message */}
+
+
+ Your settings were modified on another device or tab. Please choose which version to keep.
+
+
+
+ {/* Conflict Summary */}
+
+
Conflict Summary
+
+
+ {formattedConflict.conflictingFieldsCount > 0
+ ? `${formattedConflict.conflictingFieldsCount} field${formattedConflict.conflictingFieldsCount > 1 ? 's' : ''} have conflicting values`
+ : 'No direct conflicts, but changes exist in both versions'}
+
+
+
+
+ {/* Conflict Details */}
+ {conflictInfo.conflictingFields.length > 0 && (
+
+
Conflicting Fields
+
+ {conflictInfo.conflictingFields.map((field, index) => (
+
+
+
+ {field.field.replace(/_/g, ' ')}
+
+
+
+ {/* Local Value */}
+
+
+
+ Your Local Changes
+
+
+
+ {formatValue(field.local)}
+
+
+
+
+ {/* Remote Value */}
+
+
+
+ Remote Changes
+
+
+
+ {formatValue(field.remote)}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Data Loss Warnings */}
+ {dataLossAnalysis && (
+
+ {dataLossAnalysis.local.willLoseData && (
+
+
+ ⚠️ Keeping remote version will lose {dataLossAnalysis.local.lostFieldsCount} local change{dataLossAnalysis.local.lostFieldsCount > 1 ? 's' : ''}
+
+
+ )}
+ {dataLossAnalysis.remote.willLoseData && (
+
+
+ ⚠️ Keeping local version will lose {dataLossAnalysis.remote.lostFieldsCount} remote change{dataLossAnalysis.remote.lostFieldsCount > 1 ? 's' : ''}
+
+
+ )}
+
+ )}
+
+ {/* Actions */}
+
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/apps/web/components/SettingsPageWrapper.jsx b/apps/web/components/SettingsPageWrapper.jsx
index 2cce0ee..440d788 100644
--- a/apps/web/components/SettingsPageWrapper.jsx
+++ b/apps/web/components/SettingsPageWrapper.jsx
@@ -1,11 +1,13 @@
'use client';
-import { useState, createContext, useContext } from 'react';
+import { useState, useEffect, createContext, useContext } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { User, Shield, Bell, Settings as SettingsIcon, Save, AlertCircle } from 'lucide-react';
import SettingsNav from '@/components/SettingsNav';
import SettingsSyncIndicator from '@/components/SettingsSyncIndicator';
+import SettingsConflictDialog from '@/components/SettingsConflictDialog';
+import useSettingsStore from '@/store/settingsStore';
// Context for managing unsaved changes across settings pages
const SettingsContext = createContext(null);
@@ -51,6 +53,91 @@ export default function SettingsPageWrapper({ children }) {
const [isSaving, setIsSaving] = useState(false);
const [formSubmitHandler, setFormSubmitHandler] = useState(null);
const [formResetHandler, setFormResetHandler] = useState(null);
+
+ // Conflict dialog state
+ const [conflictDialog, setConflictDialog] = useState({
+ isOpen: false,
+ type: null,
+ localData: null,
+ remoteData: null,
+ });
+
+ const conflicts = useSettingsStore((state) => state.conflicts);
+
+ // Listen for conflict detection events
+ useEffect(() => {
+ const handleConflictDetected = (event) => {
+ const { type, localData, remoteData } = event.detail;
+ setConflictDialog({
+ isOpen: true,
+ type,
+ localData,
+ remoteData,
+ });
+ };
+
+ window.addEventListener('settings-conflict-detected', handleConflictDetected);
+
+ // Also check store for pending conflicts
+ if (conflicts) {
+ Object.entries(conflicts).forEach(([type, conflict]) => {
+ if (conflict.needsResolution && !conflictDialog.isOpen) {
+ setConflictDialog({
+ isOpen: true,
+ type,
+ localData: conflict.local,
+ remoteData: conflict.remote,
+ });
+ }
+ });
+ }
+
+ return () => {
+ window.removeEventListener('settings-conflict-detected', handleConflictDetected);
+ };
+ }, [conflicts, conflictDialog.isOpen]);
+
+ // Handle conflict resolution
+ const handleConflictResolve = (resolvedData, choice) => {
+ const { type } = conflictDialog;
+
+ // Update store with resolved data
+ const store = useSettingsStore.getState();
+ switch (type) {
+ case 'profile':
+ store.setProfile(resolvedData, { optimistic: false });
+ break;
+ case 'privacy':
+ store.setPrivacy(resolvedData, { optimistic: false });
+ break;
+ case 'notifications':
+ store.setNotifications(resolvedData, { optimistic: false });
+ break;
+ }
+
+ // Clear conflict from store
+ useSettingsStore.setState((state) => {
+ const newConflicts = { ...state.conflicts };
+ delete newConflicts[type];
+ return { conflicts: newConflicts };
+ });
+
+ setConflictDialog({
+ isOpen: false,
+ type: null,
+ localData: null,
+ remoteData: null,
+ });
+ };
+
+ const handleConflictClose = () => {
+ setConflictDialog({
+ isOpen: false,
+ type: null,
+ localData: null,
+ remoteData: null,
+ });
+ };
// Handle save changes
const handleSaveChanges = async () => {
@@ -204,6 +291,16 @@ export default function SettingsPageWrapper({ children }) {
+
+ {/* Conflict Dialog */}
+
);
diff --git a/apps/web/components/ValidationError.jsx b/apps/web/components/ValidationError.jsx
new file mode 100644
index 0000000..5609bd3
--- /dev/null
+++ b/apps/web/components/ValidationError.jsx
@@ -0,0 +1,326 @@
+'use client';
+
+import { AlertCircle, X } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+
+/**
+ * ValidationError Component
+ *
+ * Reusable component for displaying validation errors with:
+ * - Error messages below input fields
+ * - Red styling with warning icon
+ * - Animation on error appearance
+ * - Accessibility (ARIA live region)
+ * - Support for multiple errors per field
+ * - Clear, user-friendly error messages
+ *
+ * @param {Object} props
+ * @param {string|string[]|Object} props.error - Error message(s) or error object
+ * @param {string} props.fieldName - Field name for accessibility
+ * @param {boolean} props.showIcon - Whether to show the warning icon (default: true)
+ * @param {boolean} props.animate - Whether to animate error appearance (default: true)
+ * @param {string} props.className - Additional CSS classes
+ * @param {boolean} props.inline - Whether to display inline (default: false)
+ */
+export default function ValidationError({
+ error,
+ fieldName,
+ showIcon = true,
+ animate = true,
+ className = '',
+ inline = false,
+}) {
+ const [isVisible, setIsVisible] = useState(false);
+ const [hasError, setHasError] = useState(false);
+ const prevErrorRef = useRef(error);
+ const announceRef = useRef(null);
+
+ // Track error changes for animation
+ useEffect(() => {
+ const hasErrorNow = Boolean(error);
+ const hadErrorBefore = Boolean(prevErrorRef.current);
+
+ if (hasErrorNow && !hadErrorBefore) {
+ // Error just appeared
+ setIsVisible(true);
+ setHasError(true);
+
+ // Announce error to screen readers
+ if (announceRef.current) {
+ announceRef.current.textContent = getErrorMessage(error);
+ }
+ } else if (!hasErrorNow && hadErrorBefore) {
+ // Error just disappeared
+ setIsVisible(false);
+ setTimeout(() => setHasError(false), 200); // Wait for fade-out
+ } else if (hasErrorNow) {
+ // Error still exists, update message
+ setHasError(true);
+ if (announceRef.current) {
+ announceRef.current.textContent = getErrorMessage(error);
+ }
+ }
+
+ prevErrorRef.current = error;
+ }, [error]);
+
+ // Helper function to extract error message
+ const getErrorMessage = (error) => {
+ if (!error) return null;
+
+ // Handle different error formats
+ if (typeof error === 'string') {
+ return error;
+ }
+
+ if (Array.isArray(error)) {
+ return error.join(', ');
+ }
+
+ if (typeof error === 'object') {
+ // React Hook Form error format: { message: string, type: string }
+ if (error.message) {
+ return error.message;
+ }
+
+ // Multiple errors: { field1: 'error1', field2: 'error2' }
+ const messages = Object.values(error).filter(Boolean);
+ return messages.length > 0 ? messages.join(', ') : null;
+ }
+
+ return null;
+ };
+
+ // Helper function to get all error messages
+ const getErrorMessages = (error) => {
+ if (!error) return [];
+
+ if (typeof error === 'string') {
+ return [error];
+ }
+
+ if (Array.isArray(error)) {
+ return error;
+ }
+
+ if (typeof error === 'object') {
+ if (error.message) {
+ return [error.message];
+ }
+
+ // Multiple errors
+ return Object.values(error).filter(Boolean);
+ }
+
+ return [];
+ };
+
+ const messages = getErrorMessages(error);
+
+ if (!hasError || messages.length === 0) {
+ return null;
+ }
+
+ // Inline display (for helper text replacement)
+ if (inline) {
+ return (
+ <>
+
+ {showIcon && (
+
+ )}
+
+ {messages.map((message, index) => (
+
0 && 'mt-1',
+ ].filter(Boolean).join(' ')}
+ >
+ {message}
+
+ ))}
+
+
+ {/* Screen reader announcement */}
+
+ >
+ );
+ }
+
+ // Block display (below input field)
+ return (
+ <>
+
+ {showIcon && (
+
+ )}
+
+ {messages.map((message, index) => (
+
0 && 'text-xs',
+ ].filter(Boolean).join(' ')}
+ >
+ {message}
+
+ ))}
+
+
+ {/* Screen reader announcement */}
+
+ >
+ );
+}
+
+/**
+ * FieldError Helper Component
+ *
+ * Simplified wrapper for common use case: displaying single error below input
+ *
+ * @param {Object} props
+ * @param {string|Object} props.error - Error message or error object
+ * @param {string} props.fieldName - Field name
+ * @param {string} props.className - Additional CSS classes
+ */
+export function FieldError({ error, fieldName, className = '' }) {
+ return (
+
+ );
+}
+
+/**
+ * InlineError Helper Component
+ *
+ * For displaying errors inline with helper text (replaces helper text when error exists)
+ *
+ * @param {Object} props
+ * @param {string|Object} props.error - Error message or error object
+ * @param {ReactNode} props.children - Helper text to show when no error
+ * @param {string} props.fieldName - Field name
+ * @param {string} props.className - Additional CSS classes
+ */
+export function InlineError({ error, children, fieldName, className = '' }) {
+ if (error) {
+ return (
+
+ );
+ }
+
+ return
{children}
;
+}
+
+/**
+ * ValidationSummary Component
+ *
+ * Displays all validation errors for a form in a summary box
+ *
+ * @param {Object} props
+ * @param {Object} props.errors - Object with field names as keys and errors as values
+ * @param {string} props.title - Summary title (default: "Please fix the following errors")
+ * @param {string} props.className - Additional CSS classes
+ */
+export function ValidationSummary({ errors, title, className = '' }) {
+ const errorEntries = Object.entries(errors || {}).filter(([_, error]) => Boolean(error));
+
+ if (errorEntries.length === 0) {
+ return null;
+ }
+
+ const getErrorMessage = (error) => {
+ if (typeof error === 'string') return error;
+ if (error?.message) return error.message;
+ return 'Validation error';
+ };
+
+ return (
+
+
+
+
+
+ {title || 'Please fix the following errors'}
+
+
+ {errorEntries.map(([field, error]) => (
+ -
+ {field.replace(/_/g, ' ')}:{' '}
+ {getErrorMessage(error)}
+
+ ))}
+
+
+
+
+ );
+}
+
diff --git a/apps/web/hooks/useSettingsSync.js b/apps/web/hooks/useSettingsSync.js
index 8aa68f8..00ba8ca 100644
--- a/apps/web/hooks/useSettingsSync.js
+++ b/apps/web/hooks/useSettingsSync.js
@@ -329,15 +329,23 @@ export function useSettingsSync(options = {}) {
return;
}
+ // Import conflict resolution utilities
+ const {
+ resolveConflict,
+ ConflictResolutionStrategy,
+ } = await import('@/lib/utils/settingsConflictResolver');
+
if (conflictResolution === 'remote') {
// Last write wins (remote)
- updateStoreWithRemoteData(type, remoteData);
+ const resolution = resolveConflict(type, localData, remoteData, ConflictResolutionStrategy.REMOTE);
+ updateStoreWithRemoteData(type, resolution.resolved);
if (showNotifications) {
showConflictResolvedNotification(type, 'remote');
}
} else if (conflictResolution === 'local') {
// Keep local changes
+ const resolution = resolveConflict(type, localData, remoteData, ConflictResolutionStrategy.LOCAL);
// Don't update store, but mark conflict
useSettingsStore.setState((state) => ({
conflicts: {
@@ -346,6 +354,7 @@ export function useSettingsSync(options = {}) {
local: localData,
remote: remoteData,
detectedAt: new Date().toISOString(),
+ resolution: 'local',
},
},
}));
@@ -353,8 +362,8 @@ export function useSettingsSync(options = {}) {
if (showNotifications) {
showConflictNotification(type);
}
- } else if (conflictResolution === 'prompt') {
- // Show conflict notification (UI should prompt user)
+ } else if (conflictResolution === 'prompt' || conflictResolution === 'user_choice') {
+ // Show conflict dialog for user to choose
useSettingsStore.setState((state) => ({
conflicts: {
...state.conflicts,
@@ -362,13 +371,64 @@ export function useSettingsSync(options = {}) {
local: localData,
remote: remoteData,
detectedAt: new Date().toISOString(),
+ needsResolution: true,
},
},
}));
+ // Dispatch event to show conflict dialog
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('settings-conflict-detected', {
+ detail: {
+ type,
+ localData,
+ remoteData,
+ },
+ }));
+ }
+
if (showNotifications) {
showConflictPromptNotification(type);
}
+ } else if (conflictResolution === 'merge') {
+ // Try to merge non-conflicting changes
+ const resolution = resolveConflict(type, localData, remoteData, ConflictResolutionStrategy.MERGE);
+
+ if (resolution.requiresUserInput) {
+ // Has conflicts that need user input
+ useSettingsStore.setState((state) => ({
+ conflicts: {
+ ...state.conflicts,
+ [type]: {
+ local: localData,
+ remote: remoteData,
+ detectedAt: new Date().toISOString(),
+ needsResolution: true,
+ remainingConflicts: resolution.conflicts,
+ },
+ },
+ }));
+
+ // Dispatch event to show conflict dialog
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('settings-conflict-detected', {
+ detail: {
+ type,
+ localData,
+ remoteData,
+ mergedData: resolution.resolved,
+ remainingConflicts: resolution.conflicts,
+ },
+ }));
+ }
+ } else {
+ // No conflicts, can auto-merge
+ updateStoreWithRemoteData(type, resolution.resolved);
+
+ if (showNotifications) {
+ showConflictResolvedNotification(type, 'merged');
+ }
+ }
}
};
diff --git a/apps/web/hooks/useSettingsValidation.js b/apps/web/hooks/useSettingsValidation.js
new file mode 100644
index 0000000..03b74ce
--- /dev/null
+++ b/apps/web/hooks/useSettingsValidation.js
@@ -0,0 +1,248 @@
+'use client';
+
+import { useCallback, useMemo } from 'react';
+import { profileSchema, privacySchema, notificationSchema } from '@/lib/schemas';
+
+/**
+ * Settings Validation Hook
+ *
+ * Provides client-side validation utilities for settings forms.
+ * Features:
+ * - Real-time validation as user types
+ * - Field-specific validation
+ * - Custom error messages
+ * - Validation state management
+ *
+ * @param {string} type - Settings type: 'profile', 'privacy', or 'notifications'
+ * @returns {Object} Validation utilities and state
+ */
+export function useSettingsValidation(type) {
+ // Get the appropriate schema
+ const schema = useMemo(() => {
+ switch (type) {
+ case 'profile':
+ return profileSchema;
+ case 'privacy':
+ return privacySchema;
+ case 'notifications':
+ return notificationSchema;
+ default:
+ return null;
+ }
+ }, [type]);
+
+ /**
+ * Validate entire form data
+ * @param {Object} data - Form data to validate
+ * @returns {Object} Validation result
+ */
+ const validate = useCallback((data) => {
+ if (!schema) {
+ return {
+ success: false,
+ error: 'Invalid validation type',
+ errors: {},
+ };
+ }
+
+ const result = schema.safeParse(data);
+
+ if (result.success) {
+ return {
+ success: true,
+ data: result.data,
+ errors: {},
+ };
+ }
+
+ // Transform Zod errors into a flat object keyed by field name
+ const errors = {};
+ result.error.errors.forEach((error) => {
+ const path = error.path.join('.');
+ errors[path] = error.message;
+ });
+
+ return {
+ success: false,
+ error: 'Validation failed',
+ errors,
+ zodError: result.error,
+ };
+ }, [schema]);
+
+ /**
+ * Validate a single field
+ * @param {string} field - Field name to validate
+ * @param {any} value - Field value
+ * @returns {Object} Field validation result
+ */
+ const validateField = useCallback((field, value) => {
+ if (!schema) {
+ return {
+ success: false,
+ error: 'Invalid validation type',
+ };
+ }
+
+ const fieldSchema = schema.shape[field];
+ if (!fieldSchema) {
+ return {
+ success: false,
+ error: `Unknown field: ${field}`,
+ };
+ }
+
+ const result = fieldSchema.safeParse(value);
+
+ return {
+ success: result.success,
+ error: result.success ? null : result.error.errors[0]?.message || 'Invalid value',
+ field,
+ };
+ }, [schema]);
+
+ /**
+ * Validate multiple fields at once
+ * @param {Object} fields - Object with field names as keys and values as values
+ * @returns {Object} Validation results for each field
+ */
+ const validateFields = useCallback((fields) => {
+ if (!schema) {
+ return {
+ success: false,
+ errors: {},
+ };
+ }
+
+ const errors = {};
+ let allValid = true;
+
+ Object.entries(fields).forEach(([field, value]) => {
+ const fieldResult = validateField(field, value);
+ if (!fieldResult.success) {
+ errors[field] = fieldResult.error;
+ allValid = false;
+ }
+ });
+
+ return {
+ success: allValid,
+ errors,
+ };
+ }, [schema, validateField]);
+
+ /**
+ * Check if data would be valid without actually parsing
+ * (lightweight check)
+ * @param {Object} data - Data to check
+ * @returns {boolean} True if data appears valid
+ */
+ const isValid = useCallback((data) => {
+ if (!schema) return false;
+ return schema.safeParse(data).success;
+ }, [schema]);
+
+ /**
+ * Get validation rules for a specific field
+ * @param {string} field - Field name
+ * @returns {Object|null} Field validation rules
+ */
+ const getFieldRules = useCallback((field) => {
+ if (!schema) return null;
+
+ const fieldSchema = schema.shape[field];
+ if (!fieldSchema) return null;
+
+ const rules = {
+ field,
+ required: false,
+ min: null,
+ max: null,
+ pattern: null,
+ type: null,
+ };
+
+ // Extract rules from schema (best effort)
+ // Zod schemas are complex, so we extract what we can
+ if (fieldSchema._def?.typeName === 'ZodString') {
+ rules.type = 'string';
+
+ // Check for min length
+ if (fieldSchema._def.checks) {
+ fieldSchema._def.checks.forEach((check) => {
+ if (check.kind === 'min') {
+ rules.min = check.value;
+ rules.required = true; // If there's a min, field is likely required
+ }
+ if (check.kind === 'max') {
+ rules.max = check.value;
+ }
+ if (check.kind === 'regex') {
+ rules.pattern = check.regex;
+ }
+ });
+ }
+ } else if (fieldSchema._def?.typeName === 'ZodBoolean') {
+ rules.type = 'boolean';
+ rules.required = true; // Booleans are typically required
+ } else if (fieldSchema._def?.typeName === 'ZodEnum') {
+ rules.type = 'enum';
+ rules.enum = fieldSchema._def.values;
+ rules.required = true;
+ }
+
+ return rules;
+ }, [schema]);
+
+ /**
+ * Get all validation errors for form data
+ * Returns errors in a format compatible with React Hook Form
+ * @param {Object} data - Form data
+ * @returns {Object} Errors object keyed by field name
+ */
+ const getErrors = useCallback((data) => {
+ const result = validate(data);
+ return result.errors || {};
+ }, [validate]);
+
+ /**
+ * Get error message for a specific field
+ * @param {Object} errors - Errors object from getErrors
+ * @param {string} field - Field name
+ * @returns {string|null} Error message or null
+ */
+ const getFieldError = useCallback((errors, field) => {
+ if (!errors || !errors[field]) return null;
+ return errors[field];
+ }, []);
+
+ /**
+ * Check if form has any errors
+ * @param {Object} errors - Errors object
+ * @returns {boolean} True if form has errors
+ */
+ const hasErrors = useCallback((errors) => {
+ if (!errors) return false;
+ return Object.keys(errors).length > 0;
+ }, []);
+
+ return {
+ // Validation functions
+ validate,
+ validateField,
+ validateFields,
+ isValid,
+
+ // Field information
+ getFieldRules,
+
+ // Error utilities
+ getErrors,
+ getFieldError,
+ hasErrors,
+
+ // Schema reference
+ schema,
+ };
+}
+
diff --git a/apps/web/lib/schemas/__tests__/notificationSchema.test.js b/apps/web/lib/schemas/__tests__/notificationSchema.test.js
new file mode 100644
index 0000000..80de947
--- /dev/null
+++ b/apps/web/lib/schemas/__tests__/notificationSchema.test.js
@@ -0,0 +1,526 @@
+import { describe, it, expect } from 'vitest';
+import { notificationSchema, notificationPartialSchema, getDefaultNotificationPreferences } from '../notificationSchema.js';
+
+describe('Notification Schema Validation', () => {
+ describe('Valid Inputs', () => {
+ it('should pass validation with all valid fields', () => {
+ const validData = {
+ friend_requests_inapp: true,
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: true,
+ security_alerts_email: true,
+ email_frequency: 'instant',
+ notifications_enabled: true,
+ };
+
+ const result = notificationSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with default settings', () => {
+ const defaultSettings = getDefaultNotificationPreferences();
+ const result = notificationSchema.safeParse(defaultSettings);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with all notifications disabled (except security)', () => {
+ const validData = {
+ friend_requests_inapp: false,
+ friend_requests_email: false,
+ new_followers_inapp: false,
+ new_followers_email: false,
+ comments_inapp: false,
+ comments_email: false,
+ playlist_invites_inapp: false,
+ playlist_invites_email: false,
+ playlist_updates_inapp: false,
+ playlist_updates_email: false,
+ song_of_day_inapp: false,
+ song_of_day_email: false,
+ system_announcements_inapp: false,
+ system_announcements_email: false,
+ security_alerts_inapp: true, // Must be true
+ security_alerts_email: true, // Must be true
+ email_frequency: 'weekly',
+ notifications_enabled: false,
+ };
+
+ const result = notificationSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with all email frequency options', () => {
+ const frequencies = ['instant', 'daily', 'weekly'];
+
+ frequencies.forEach(frequency => {
+ const validData = {
+ friend_requests_inapp: true,
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: true,
+ comments_inapp: true,
+ comments_email: true,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: true,
+ song_of_day_inapp: true,
+ song_of_day_email: true,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: true,
+ security_alerts_email: true,
+ email_frequency: frequency,
+ notifications_enabled: true,
+ };
+
+ const result = notificationSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+ });
+ });
+
+ describe('Security Alerts Enforcement', () => {
+ it('should fail validation when security_alerts_inapp is false', () => {
+ const invalidData = {
+ friend_requests_inapp: true,
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: false, // Invalid: must be true
+ security_alerts_email: true,
+ email_frequency: 'instant',
+ notifications_enabled: true,
+ };
+
+ const result = notificationSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e =>
+ e.path.includes('security_alerts_inapp') ||
+ e.message.includes('Security alerts must always be enabled')
+ );
+ expect(error).toBeDefined();
+ }
+ });
+
+ it('should fail validation when security_alerts_email is false', () => {
+ const invalidData = {
+ friend_requests_inapp: true,
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: true,
+ security_alerts_email: false, // Invalid: must be true
+ email_frequency: 'instant',
+ notifications_enabled: true,
+ };
+
+ const result = notificationSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e =>
+ e.path.includes('security_alerts_email') ||
+ e.message.includes('Security alerts must always be enabled')
+ );
+ expect(error).toBeDefined();
+ }
+ });
+
+ it('should fail validation when both security alerts are false', () => {
+ const invalidData = {
+ friend_requests_inapp: true,
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: false, // Invalid
+ security_alerts_email: false, // Invalid
+ email_frequency: 'instant',
+ notifications_enabled: true,
+ };
+
+ const result = notificationSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should pass validation when both security alerts are true', () => {
+ const validData = {
+ friend_requests_inapp: true,
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: true,
+ security_alerts_email: true,
+ email_frequency: 'instant',
+ notifications_enabled: true,
+ };
+
+ const result = notificationSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe('Invalid Email Frequency', () => {
+ it('should fail validation when email_frequency is invalid', () => {
+ const invalidData = {
+ friend_requests_inapp: true,
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: true,
+ security_alerts_email: true,
+ email_frequency: 'invalid',
+ notifications_enabled: true,
+ };
+
+ const result = notificationSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e => e.path.includes('email_frequency'));
+ expect(error?.message).toContain('instant, daily, or weekly');
+ }
+ });
+
+ it('should fail validation when email_frequency is missing', () => {
+ const invalidData = {
+ friend_requests_inapp: true,
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: true,
+ security_alerts_email: true,
+ notifications_enabled: true,
+ };
+
+ const result = notificationSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('Invalid Boolean Values', () => {
+ it('should fail validation when notification toggle is not a boolean', () => {
+ const invalidData = {
+ friend_requests_inapp: 'true',
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: true,
+ security_alerts_email: true,
+ email_frequency: 'instant',
+ notifications_enabled: true,
+ };
+
+ const result = notificationSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when notifications_enabled is not a boolean', () => {
+ const invalidData = {
+ friend_requests_inapp: true,
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: true,
+ security_alerts_email: true,
+ email_frequency: 'instant',
+ notifications_enabled: 'yes',
+ };
+
+ const result = notificationSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('Missing Required Fields', () => {
+ it('should fail validation when friend_requests_inapp is missing', () => {
+ const invalidData = {
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: true,
+ security_alerts_email: true,
+ email_frequency: 'instant',
+ notifications_enabled: true,
+ };
+
+ const result = notificationSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when multiple fields are missing', () => {
+ const invalidData = {
+ friend_requests_inapp: true,
+ security_alerts_inapp: true,
+ security_alerts_email: true,
+ email_frequency: 'instant',
+ };
+
+ const result = notificationSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('Partial Schema', () => {
+ it('should pass validation with partial schema (only friend_requests_inapp)', () => {
+ const partialData = {
+ friend_requests_inapp: false,
+ };
+
+ const result = notificationPartialSchema.safeParse(partialData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with partial schema (multiple fields)', () => {
+ const partialData = {
+ friend_requests_inapp: false,
+ friend_requests_email: true,
+ email_frequency: 'daily',
+ };
+
+ const result = notificationPartialSchema.safeParse(partialData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with empty object (all optional in partial)', () => {
+ const partialData = {};
+
+ const result = notificationPartialSchema.safeParse(partialData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should still enforce security alerts in partial schema', () => {
+ const invalidData = {
+ security_alerts_inapp: false,
+ };
+
+ const result = notificationPartialSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should still validate enum values in partial schema', () => {
+ const invalidData = {
+ email_frequency: 'invalid',
+ };
+
+ const result = notificationPartialSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should still validate boolean types in partial schema', () => {
+ const invalidData = {
+ friend_requests_inapp: 'maybe',
+ };
+
+ const result = notificationPartialSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle null input', () => {
+ const result = notificationSchema.safeParse(null);
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle undefined input', () => {
+ const result = notificationSchema.safeParse(undefined);
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle empty object', () => {
+ const result = notificationSchema.safeParse({});
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle extra fields gracefully', () => {
+ const validData = {
+ friend_requests_inapp: true,
+ friend_requests_email: true,
+ new_followers_inapp: true,
+ new_followers_email: false,
+ comments_inapp: true,
+ comments_email: false,
+ playlist_invites_inapp: true,
+ playlist_invites_email: true,
+ playlist_updates_inapp: true,
+ playlist_updates_email: false,
+ song_of_day_inapp: true,
+ song_of_day_email: false,
+ system_announcements_inapp: true,
+ system_announcements_email: true,
+ security_alerts_inapp: true,
+ security_alerts_email: true,
+ email_frequency: 'instant',
+ notifications_enabled: true,
+ extra_field: 'should be ignored',
+ };
+
+ const result = notificationSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.extra_field).toBeUndefined();
+ }
+ });
+ });
+
+ describe('All Boolean Combinations', () => {
+ it('should accept all boolean combinations for notification toggles', () => {
+ const combinations = [true, false];
+
+ combinations.forEach(value => {
+ const validData = {
+ friend_requests_inapp: value,
+ friend_requests_email: value,
+ new_followers_inapp: value,
+ new_followers_email: value,
+ comments_inapp: value,
+ comments_email: value,
+ playlist_invites_inapp: value,
+ playlist_invites_email: value,
+ playlist_updates_inapp: value,
+ playlist_updates_email: value,
+ song_of_day_inapp: value,
+ song_of_day_email: value,
+ system_announcements_inapp: value,
+ system_announcements_email: value,
+ security_alerts_inapp: true, // Must always be true
+ security_alerts_email: true, // Must always be true
+ email_frequency: 'instant',
+ notifications_enabled: value,
+ };
+
+ const result = notificationSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+ });
+ });
+
+ describe('Helper Functions', () => {
+ describe('getDefaultNotificationPreferences', () => {
+ it('should return valid default settings', () => {
+ const defaults = getDefaultNotificationPreferences();
+ const result = notificationSchema.safeParse(defaults);
+ expect(result.success).toBe(true);
+ });
+
+ it('should return all required fields', () => {
+ const defaults = getDefaultNotificationPreferences();
+ expect(defaults).toHaveProperty('friend_requests_inapp');
+ expect(defaults).toHaveProperty('friend_requests_email');
+ expect(defaults).toHaveProperty('security_alerts_inapp');
+ expect(defaults).toHaveProperty('security_alerts_email');
+ expect(defaults).toHaveProperty('email_frequency');
+ expect(defaults).toHaveProperty('notifications_enabled');
+ });
+
+ it('should return security alerts as true', () => {
+ const defaults = getDefaultNotificationPreferences();
+ expect(defaults.security_alerts_inapp).toBe(true);
+ expect(defaults.security_alerts_email).toBe(true);
+ });
+ });
+ });
+});
+
diff --git a/apps/web/lib/schemas/__tests__/privacySchema.test.js b/apps/web/lib/schemas/__tests__/privacySchema.test.js
new file mode 100644
index 0000000..787a8d9
--- /dev/null
+++ b/apps/web/lib/schemas/__tests__/privacySchema.test.js
@@ -0,0 +1,483 @@
+import { describe, it, expect } from 'vitest';
+import { privacySchema, privacyPartialSchema, getDefaultPrivacySettings, isMoreRestrictive } from '../privacySchema.js';
+
+describe('Privacy Schema Validation', () => {
+ describe('Valid Inputs', () => {
+ it('should pass validation with all valid fields', () => {
+ const validData = {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'everyone',
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with private profile and appropriate settings', () => {
+ const validData = {
+ profile_visibility: 'private',
+ playlist_visibility: 'private',
+ listening_activity_visible: false,
+ song_of_day_visibility: 'private',
+ friend_request_setting: 'nobody',
+ searchable: false,
+ activity_feed_visible: false,
+ };
+
+ const result = privacySchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with friends visibility', () => {
+ const validData = {
+ profile_visibility: 'friends',
+ playlist_visibility: 'friends',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'friends',
+ friend_request_setting: 'friends_of_friends',
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with default settings', () => {
+ const defaultSettings = getDefaultPrivacySettings();
+ const result = privacySchema.safeParse(defaultSettings);
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe('Invalid Enum Values', () => {
+ it('should fail validation when profile_visibility is invalid', () => {
+ const invalidData = {
+ profile_visibility: 'invalid',
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'everyone',
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e => e.path.includes('profile_visibility'));
+ expect(error?.message).toContain('public, friends, or private');
+ }
+ });
+
+ it('should fail validation when playlist_visibility is invalid', () => {
+ const invalidData = {
+ profile_visibility: 'public',
+ playlist_visibility: 'invalid',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'everyone',
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when song_of_day_visibility is invalid', () => {
+ const invalidData = {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'invalid',
+ friend_request_setting: 'everyone',
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when friend_request_setting is invalid', () => {
+ const invalidData = {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'invalid',
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('Invalid Boolean Values', () => {
+ it('should fail validation when listening_activity_visible is not a boolean', () => {
+ const invalidData = {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity_visible: 'true',
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'everyone',
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when searchable is not a boolean', () => {
+ const invalidData = {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'everyone',
+ searchable: 'yes',
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when activity_feed_visible is not a boolean', () => {
+ const invalidData = {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'everyone',
+ searchable: true,
+ activity_feed_visible: 1,
+ };
+
+ const result = privacySchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('Missing Required Fields', () => {
+ it('should fail validation when profile_visibility is missing', () => {
+ const invalidData = {
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'everyone',
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when multiple fields are missing', () => {
+ const invalidData = {
+ profile_visibility: 'public',
+ };
+
+ const result = privacySchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('Privacy Combination Validation', () => {
+ it('should fail validation when profile is private but searchable is true', () => {
+ const invalidData = {
+ profile_visibility: 'private',
+ playlist_visibility: 'private',
+ listening_activity_visible: false,
+ song_of_day_visibility: 'private',
+ friend_request_setting: 'nobody',
+ searchable: true, // Invalid: private profile cannot be searchable
+ activity_feed_visible: false,
+ };
+
+ const result = privacySchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e =>
+ e.message.includes('searchable') || e.message.includes('Private profiles')
+ );
+ expect(error).toBeDefined();
+ }
+ });
+
+ it('should fail validation when profile is private but activity_feed_visible is true', () => {
+ const invalidData = {
+ profile_visibility: 'private',
+ playlist_visibility: 'private',
+ listening_activity_visible: false,
+ song_of_day_visibility: 'private',
+ friend_request_setting: 'nobody',
+ searchable: false,
+ activity_feed_visible: true, // Invalid: private profile cannot have visible activity feed
+ };
+
+ const result = privacySchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e =>
+ e.message.includes('activity feed') || e.message.includes('Private profiles')
+ );
+ expect(error).toBeDefined();
+ }
+ });
+
+ it('should pass validation when profile is private with all appropriate restrictions', () => {
+ const validData = {
+ profile_visibility: 'private',
+ playlist_visibility: 'private',
+ listening_activity_visible: false,
+ song_of_day_visibility: 'private',
+ friend_request_setting: 'nobody',
+ searchable: false,
+ activity_feed_visible: false,
+ };
+
+ const result = privacySchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation when profile is public with searchable and activity feed visible', () => {
+ const validData = {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'everyone',
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation when profile is friends with searchable and activity feed visible', () => {
+ const validData = {
+ profile_visibility: 'friends',
+ playlist_visibility: 'friends',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'friends',
+ friend_request_setting: 'friends_of_friends',
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe('Partial Schema', () => {
+ it('should pass validation with partial schema (only profile_visibility)', () => {
+ const partialData = {
+ profile_visibility: 'private',
+ };
+
+ const result = privacyPartialSchema.safeParse(partialData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with partial schema (multiple fields)', () => {
+ const partialData = {
+ profile_visibility: 'friends',
+ searchable: false,
+ };
+
+ const result = privacyPartialSchema.safeParse(partialData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with empty object (all optional in partial)', () => {
+ const partialData = {};
+
+ const result = privacyPartialSchema.safeParse(partialData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should still validate enum values in partial schema', () => {
+ const invalidData = {
+ profile_visibility: 'invalid',
+ };
+
+ const result = privacyPartialSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle null input', () => {
+ const result = privacySchema.safeParse(null);
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle undefined input', () => {
+ const result = privacySchema.safeParse(undefined);
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle empty object', () => {
+ const result = privacySchema.safeParse({});
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle extra fields gracefully', () => {
+ const validData = {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'everyone',
+ searchable: true,
+ activity_feed_visible: true,
+ extra_field: 'should be ignored',
+ };
+
+ const result = privacySchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.extra_field).toBeUndefined();
+ }
+ });
+ });
+
+ describe('All Enum Values', () => {
+ it('should accept all valid profile_visibility values', () => {
+ const values = ['public', 'friends', 'private'];
+
+ values.forEach(value => {
+ const data = {
+ profile_visibility: value,
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'everyone',
+ searchable: value !== 'private', // Adjust based on privacy rules
+ activity_feed_visible: value !== 'private',
+ };
+
+ const result = privacySchema.safeParse(data);
+ expect(result.success).toBe(true);
+ });
+ });
+
+ it('should accept all valid friend_request_setting values', () => {
+ const values = ['everyone', 'friends_of_friends', 'nobody'];
+
+ values.forEach(value => {
+ const data = {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: value,
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(data);
+ expect(result.success).toBe(true);
+ });
+ });
+ });
+
+ describe('Boolean Values', () => {
+ it('should accept true for all boolean fields', () => {
+ const data = {
+ profile_visibility: 'public',
+ playlist_visibility: 'public',
+ listening_activity_visible: true,
+ song_of_day_visibility: 'public',
+ friend_request_setting: 'everyone',
+ searchable: true,
+ activity_feed_visible: true,
+ };
+
+ const result = privacySchema.safeParse(data);
+ expect(result.success).toBe(true);
+ });
+
+ it('should accept false for all boolean fields', () => {
+ const data = {
+ profile_visibility: 'private',
+ playlist_visibility: 'private',
+ listening_activity_visible: false,
+ song_of_day_visibility: 'private',
+ friend_request_setting: 'nobody',
+ searchable: false,
+ activity_feed_visible: false,
+ };
+
+ const result = privacySchema.safeParse(data);
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe('Helper Functions', () => {
+ describe('getDefaultPrivacySettings', () => {
+ it('should return valid default settings', () => {
+ const defaults = getDefaultPrivacySettings();
+ const result = privacySchema.safeParse(defaults);
+ expect(result.success).toBe(true);
+ });
+
+ it('should return all required fields', () => {
+ const defaults = getDefaultPrivacySettings();
+ expect(defaults).toHaveProperty('profile_visibility');
+ expect(defaults).toHaveProperty('playlist_visibility');
+ expect(defaults).toHaveProperty('listening_activity_visible');
+ expect(defaults).toHaveProperty('song_of_day_visibility');
+ expect(defaults).toHaveProperty('friend_request_setting');
+ expect(defaults).toHaveProperty('searchable');
+ expect(defaults).toHaveProperty('activity_feed_visible');
+ });
+ });
+
+ describe('isMoreRestrictive', () => {
+ it('should return true when moving from public to friends', () => {
+ expect(isMoreRestrictive('public', 'friends')).toBe(true);
+ });
+
+ it('should return true when moving from public to private', () => {
+ expect(isMoreRestrictive('public', 'private')).toBe(true);
+ });
+
+ it('should return true when moving from friends to private', () => {
+ expect(isMoreRestrictive('friends', 'private')).toBe(true);
+ });
+
+ it('should return false when moving from private to friends', () => {
+ expect(isMoreRestrictive('private', 'friends')).toBe(false);
+ });
+
+ it('should return false when moving from friends to public', () => {
+ expect(isMoreRestrictive('friends', 'public')).toBe(false);
+ });
+
+ it('should return false when moving from private to public', () => {
+ expect(isMoreRestrictive('private', 'public')).toBe(false);
+ });
+
+ it('should return false when moving to same level', () => {
+ expect(isMoreRestrictive('public', 'public')).toBe(false);
+ expect(isMoreRestrictive('friends', 'friends')).toBe(false);
+ expect(isMoreRestrictive('private', 'private')).toBe(false);
+ });
+ });
+ });
+});
+
diff --git a/apps/web/lib/schemas/__tests__/profileSchema.test.js b/apps/web/lib/schemas/__tests__/profileSchema.test.js
new file mode 100644
index 0000000..9a060ee
--- /dev/null
+++ b/apps/web/lib/schemas/__tests__/profileSchema.test.js
@@ -0,0 +1,428 @@
+import { describe, it, expect } from 'vitest';
+import { profileSchema } from '../profileSchema.js';
+
+describe('Profile Schema Validation', () => {
+ describe('Valid Inputs', () => {
+ it('should pass validation with valid display name, bio, and URL', () => {
+ const validData = {
+ display_name: 'John Doe',
+ bio: 'My bio',
+ profile_picture_url: 'https://example.com/image.jpg',
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.display_name).toBe('John Doe');
+ expect(result.data.bio).toBe('My bio');
+ expect(result.data.profile_picture_url).toBe('https://example.com/image.jpg');
+ }
+ });
+
+ it('should pass validation with minimal valid data (just display name)', () => {
+ const validData = {
+ display_name: 'JD',
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with empty bio', () => {
+ const validData = {
+ display_name: 'John Doe',
+ bio: '',
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.bio).toBeUndefined();
+ }
+ });
+
+ it('should pass validation with null bio', () => {
+ const validData = {
+ display_name: 'John Doe',
+ bio: null,
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with null profile picture URL', () => {
+ const validData = {
+ display_name: 'John Doe',
+ profile_picture_url: null,
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with empty profile picture URL', () => {
+ const validData = {
+ display_name: 'John Doe',
+ profile_picture_url: '',
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.profile_picture_url).toBeNull();
+ }
+ });
+
+ it('should trim whitespace from display name', () => {
+ const validData = {
+ display_name: ' John Doe ',
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.display_name).toBe('John Doe');
+ }
+ });
+
+ it('should pass validation with maximum length display name (50 chars)', () => {
+ const validData = {
+ display_name: 'A'.repeat(50),
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with maximum length bio (200 chars)', () => {
+ const validData = {
+ display_name: 'John Doe',
+ bio: 'A'.repeat(200),
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should pass validation with valid File object for profile picture', () => {
+ // Create a mock File object
+ const mockFile = new File([''], 'test.jpg', { type: 'image/jpeg' });
+ Object.defineProperty(mockFile, 'size', { value: 1024 * 1024 }); // 1MB
+
+ const validData = {
+ display_name: 'John Doe',
+ profile_picture_url: mockFile,
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe('Invalid Display Name', () => {
+ it('should fail validation when display name is missing', () => {
+ const invalidData = {
+ bio: 'My bio',
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.errors[0].path).toContain('display_name');
+ expect(result.error.errors[0].message).toContain('required');
+ }
+ });
+
+ it('should fail validation when display name is too short (1 character)', () => {
+ const invalidData = {
+ display_name: 'A',
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e => e.path.includes('display_name'));
+ expect(error?.message).toContain('at least 2 characters');
+ }
+ });
+
+ it('should fail validation when display name is too long (51 characters)', () => {
+ const invalidData = {
+ display_name: 'A'.repeat(51),
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e => e.path.includes('display_name'));
+ expect(error?.message).toContain('exceed 50 characters');
+ }
+ });
+
+ it('should fail validation when display name contains special characters', () => {
+ const invalidData = {
+ display_name: 'John@Doe',
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e => e.path.includes('display_name'));
+ expect(error?.message).toContain('letters, numbers, and spaces');
+ }
+ });
+
+ it('should fail validation when display name contains only spaces', () => {
+ const invalidData = {
+ display_name: ' ',
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when display name is not a string', () => {
+ const invalidData = {
+ display_name: 123,
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e => e.path.includes('display_name'));
+ expect(error?.message).toContain('string');
+ }
+ });
+
+ it('should fail validation when display name is empty string', () => {
+ const invalidData = {
+ display_name: '',
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when display name is null', () => {
+ const invalidData = {
+ display_name: null,
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when display name is undefined', () => {
+ const invalidData = {
+ display_name: undefined,
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('Invalid Bio', () => {
+ it('should fail validation when bio exceeds 200 characters', () => {
+ const invalidData = {
+ display_name: 'John Doe',
+ bio: 'A'.repeat(201),
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e => e.path.includes('bio'));
+ expect(error?.message).toContain('exceed 200 characters');
+ }
+ });
+
+ it('should fail validation when bio is not a string', () => {
+ const invalidData = {
+ display_name: 'John Doe',
+ bio: 123,
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e => e.path.includes('bio'));
+ expect(error?.message).toContain('string');
+ }
+ });
+ });
+
+ describe('Invalid Profile Picture URL', () => {
+ it('should fail validation when profile picture URL is invalid', () => {
+ const invalidData = {
+ display_name: 'John Doe',
+ profile_picture_url: 'not-a-url',
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e => e.path.includes('profile_picture_url'));
+ expect(error?.message).toContain('URL');
+ }
+ });
+
+ it('should fail validation when profile picture URL is invalid File type', () => {
+ const mockFile = new File([''], 'test.txt', { type: 'text/plain' });
+
+ const invalidData = {
+ display_name: 'John Doe',
+ profile_picture_url: mockFile,
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e => e.path.includes('profile_picture_url'));
+ expect(error?.message).toContain('JPEG, PNG, WebP, or GIF');
+ }
+ });
+
+ it('should fail validation when profile picture File is too large', () => {
+ const mockFile = new File([''], 'test.jpg', { type: 'image/jpeg' });
+ Object.defineProperty(mockFile, 'size', { value: 6 * 1024 * 1024 }); // 6MB
+
+ const invalidData = {
+ display_name: 'John Doe',
+ profile_picture_url: mockFile,
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error.errors.find(e => e.path.includes('profile_picture_url'));
+ expect(error?.message).toContain('smaller than 5MB');
+ }
+ });
+
+ it('should fail validation when profile picture is not string, File, null, or empty', () => {
+ const invalidData = {
+ display_name: 'John Doe',
+ profile_picture_url: 123,
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle empty object', () => {
+ const invalidData = {};
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle null input', () => {
+ const result = profileSchema.safeParse(null);
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle undefined input', () => {
+ const result = profileSchema.safeParse(undefined);
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle extra fields gracefully', () => {
+ const validData = {
+ display_name: 'John Doe',
+ extra_field: 'should be ignored',
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.extra_field).toBeUndefined();
+ }
+ });
+
+ it('should handle boundary condition: exactly 2 characters', () => {
+ const validData = {
+ display_name: 'AB',
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should handle boundary condition: exactly 50 characters', () => {
+ const validData = {
+ display_name: 'A'.repeat(50),
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should handle boundary condition: exactly 200 characters in bio', () => {
+ const validData = {
+ display_name: 'John Doe',
+ bio: 'A'.repeat(200),
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe('Special Characters', () => {
+ it('should allow spaces in display name', () => {
+ const validData = {
+ display_name: 'John Doe Smith',
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should allow numbers in display name', () => {
+ const validData = {
+ display_name: 'John123',
+ };
+
+ const result = profileSchema.safeParse(validData);
+ expect(result.success).toBe(true);
+ });
+
+ it('should reject display names with symbols', () => {
+ const symbols = ['@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '+', '='];
+
+ symbols.forEach(symbol => {
+ const invalidData = {
+ display_name: `John${symbol}Doe`,
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+ });
+
+ describe('Type Coercion', () => {
+ it('should not coerce numbers to strings for display name', () => {
+ const invalidData = {
+ display_name: 12345,
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+
+ it('should not coerce booleans to strings for display name', () => {
+ const invalidData = {
+ display_name: true,
+ };
+
+ const result = profileSchema.safeParse(invalidData);
+ expect(result.success).toBe(false);
+ });
+ });
+});
+
diff --git a/apps/web/lib/schemas/notificationSchema.js b/apps/web/lib/schemas/notificationSchema.js
index 43604af..fab2fa4 100644
--- a/apps/web/lib/schemas/notificationSchema.js
+++ b/apps/web/lib/schemas/notificationSchema.js
@@ -191,6 +191,14 @@ export const notificationPartialSchema = notificationSchema.partial();
* ```
*/
+/**
+ * Export TypeScript-compatible types
+ * Note: These are JSDoc typedefs for JavaScript projects
+ * For TypeScript projects, use:
+ * type NotificationFormData = z.infer
;
+ * type NotificationPartialFormData = z.infer;
+ */
+
/**
* Helper function to get default notification preferences
* @returns {NotificationFormData} Default notification preferences
diff --git a/apps/web/lib/schemas/privacySchema.js b/apps/web/lib/schemas/privacySchema.js
index 9a076f6..7fb5363 100644
--- a/apps/web/lib/schemas/privacySchema.js
+++ b/apps/web/lib/schemas/privacySchema.js
@@ -142,44 +142,61 @@ export const privacySchema = z.object({
})
.refine(
/**
- * Validate allowed privacy combinations:
- * - If profile is private, it's logical that playlists could also be private
- * - If profile is private and searchable is false, that's consistent
- * - If profile is private, listening activity should probably be hidden
- *
- * This is a soft validation - we allow any combination but provide guidance
+ * Prevent invalid privacy combinations:
+ * - If profile is private and searchable is true, this is inconsistent
+ * (users can find you in search but can't view your profile)
+ * - If profile is private and activity_feed_visible is true, this is inconsistent
+ * (activity feed shows but profile is hidden)
*/
(data) => {
- // If profile is private, we don't enforce anything, but these are recommended:
- // - Playlist visibility can be anything (no restriction)
- // - Listening activity can be hidden (recommended but not required)
- // - Searchable should be false (recommended but not required)
+ // Invalid: Private profile but searchable
+ if (data.profile_visibility === 'private' && data.searchable === true) {
+ return false;
+ }
- // This refinement allows all combinations but could be extended to warn
- // about potentially inconsistent settings
+ // Invalid: Private profile but activity feed visible
+ if (data.profile_visibility === 'private' && data.activity_feed_visible === true) {
+ return false;
+ }
- return true; // Allow all combinations for now
+ return true;
},
{
- message: 'Privacy settings combination may be inconsistent',
+ message: 'Invalid privacy combination: Private profiles cannot be searchable or have visible activity feeds',
+ path: ['profile_visibility'], // Attach error to profile_visibility field
}
)
.refine(
/**
- * Additional validation: If profile is private, it makes sense that
- * searchable should be false (though not strictly required)
+ * Additional validation: Ensure searchable matches profile visibility
*/
(data) => {
- // Warning: If profile is private but searchable is true, users might
- // be confused why they can't see the profile even though it appears in search.
- // We allow this but could show a warning.
-
- return true; // Allow for now, could be made stricter
+ // If searchable is true, profile must be public or friends (not private)
+ if (data.searchable === true && data.profile_visibility === 'private') {
+ return false;
+ }
+ return true;
},
{
- message: 'If your profile is private, you may want to disable appearing in search results',
+ message: 'If your profile is private, you cannot appear in search results',
path: ['searchable'], // Attach error to searchable field
}
+)
+.refine(
+ /**
+ * Additional validation: Ensure activity feed visibility matches profile visibility
+ */
+ (data) => {
+ // If activity feed is visible, profile cannot be private
+ if (data.activity_feed_visible === true && data.profile_visibility === 'private') {
+ return false;
+ }
+ return true;
+ },
+ {
+ message: 'If your profile is private, your activity feed cannot be visible',
+ path: ['activity_feed_visible'], // Attach error to activity_feed_visible field
+ }
);
/**
@@ -212,6 +229,14 @@ export const privacyPartialSchema = privacySchema.partial();
* ```
*/
+/**
+ * Export TypeScript-compatible types
+ * Note: These are JSDoc typedefs for JavaScript projects
+ * For TypeScript projects, use:
+ * type PrivacyFormData = z.infer;
+ * type PrivacyPartialFormData = z.infer;
+ */
+
/**
* Helper function to get default privacy settings
* @returns {PrivacyFormData} Default privacy settings
diff --git a/apps/web/lib/schemas/profileSchema.js b/apps/web/lib/schemas/profileSchema.js
index 59bbb36..0797b0c 100644
--- a/apps/web/lib/schemas/profileSchema.js
+++ b/apps/web/lib/schemas/profileSchema.js
@@ -60,17 +60,38 @@ const bioSchema = z
* - Optional field
* - Must be valid URL format if provided
* - Can be empty string or null
+ * - Also accepts File objects for uploads
* - Validates URL format using Zod's built-in URL validator
*/
const profilePictureUrlSchema = z
- .string({
- invalid_type_error: 'Profile picture URL must be a string',
- })
- .url('Invalid profile picture URL format')
+ .union([
+ z.string({
+ invalid_type_error: 'Profile picture must be a URL string or File object',
+ }).url('Invalid profile picture URL format'),
+ z.instanceof(File, {
+ message: 'Profile picture must be a valid image file',
+ }).refine((file) => {
+ // Validate file type
+ const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
+ return validTypes.includes(file.type);
+ }, 'Profile picture must be a JPEG, PNG, WebP, or GIF image')
+ .refine((file) => {
+ // Validate file size (max 5MB)
+ const maxSize = 5 * 1024 * 1024; // 5MB
+ return file.size <= maxSize;
+ }, 'Profile picture must be smaller than 5MB'),
+ z.literal(''),
+ z.null(),
+ ])
.optional()
- .or(z.literal(''))
- .nullable()
- .transform((val) => (val === '' ? null : val));
+ .transform((val) => {
+ // If empty string, return null
+ if (val === '') return null;
+ // If File object, return as-is (will be handled by upload component)
+ if (val instanceof File) return val;
+ // If URL string, return as-is
+ return val;
+ });
/**
* Profile form validation schema
@@ -114,3 +135,9 @@ export const profileSchema = z.object({
* ```
*/
+/**
+ * Export TypeScript-compatible type
+ * Note: This is a JSDoc typedef for JavaScript projects
+ * For TypeScript projects, use: `type ProfileFormData = z.infer;`
+ */
+
diff --git a/apps/web/lib/utils/__tests__/sanitization.test.js b/apps/web/lib/utils/__tests__/sanitization.test.js
new file mode 100644
index 0000000..0d9f861
--- /dev/null
+++ b/apps/web/lib/utils/__tests__/sanitization.test.js
@@ -0,0 +1,377 @@
+import { describe, it, expect } from 'vitest';
+import {
+ stripHtmlTags,
+ removeDangerousChars,
+ normalizeWhitespace,
+ trimWhitespace,
+ escapeHtml,
+ unescapeHtml,
+ normalizeUnicode,
+ sanitizeText,
+ sanitizeDisplayName,
+ sanitizeBio,
+ sanitizeUsername,
+ sanitizeUrl,
+ sanitizeObject,
+ sanitizeFormData,
+ checkDangerousContent,
+} from '../sanitization.js';
+
+describe('Sanitization Utilities', () => {
+ describe('stripHtmlTags', () => {
+ it('should remove HTML tags', () => {
+ expect(stripHtmlTags('Hello
')).toBe('Hello');
+ expect(stripHtmlTags('World
')).toBe('World');
+ });
+
+ it('should remove multiple HTML tags', () => {
+ expect(stripHtmlTags('Hello World')).toBe('Hello World');
+ });
+
+ it('should remove script tags', () => {
+ expect(stripHtmlTags('Hello')).toBe('Hello');
+ });
+
+ it('should handle nested tags', () => {
+ expect(stripHtmlTags('')).toBe('Hello');
+ });
+
+ it('should handle empty tags', () => {
+ expect(stripHtmlTags('
')).toBe('');
+ });
+
+ it('should handle non-string input', () => {
+ expect(stripHtmlTags(123)).toBe('123');
+ expect(stripHtmlTags(null)).toBe('null');
+ });
+ });
+
+ describe('removeDangerousChars', () => {
+ it('should remove < and > characters', () => {
+ expect(removeDangerousChars('Hello')).toBe('HelloWorld');
+ });
+
+ it('should remove javascript: protocol', () => {
+ expect(removeDangerousChars('javascript:alert(1)')).toBe('alert(1)');
+ });
+
+ it('should remove event handlers', () => {
+ expect(removeDangerousChars('onclick=alert(1)')).toBe('alert(1)');
+ expect(removeDangerousChars('onload=evil()')).toBe('evil()');
+ });
+
+ it('should remove data: protocol', () => {
+ expect(removeDangerousChars('data:text/html,Hello');
+ expect(result).toBe('Hello');
+ expect(result).not.toContain('<');
+ expect(result).not.toContain('>');
+ });
+
+ it('should handle null input', () => {
+ expect(sanitizeText(null)).toBe('');
+ });
+
+ it('should handle undefined input', () => {
+ expect(sanitizeText(undefined)).toBe('');
+ });
+
+ it('should handle non-string input', () => {
+ expect(sanitizeText(123)).toBe('123');
+ });
+
+ it('should respect custom options', () => {
+ const result = sanitizeText(' Hello World ', {
+ normalizeWhitespace: false,
+ trim: true,
+ });
+ expect(result).toBe('Hello World');
+ });
+ });
+
+ describe('sanitizeDisplayName', () => {
+ it('should sanitize display name', () => {
+ const result = sanitizeDisplayName('John Doe');
+ expect(result).toBe('John Doe');
+ });
+
+ it('should remove dangerous characters', () => {
+ const result = sanitizeDisplayName('JohnDoe');
+ expect(result).toBe('JohnDoe');
+ });
+
+ it('should normalize whitespace', () => {
+ const result = sanitizeDisplayName('John Doe');
+ expect(result).toBe('John Doe');
+ });
+
+ it('should trim whitespace', () => {
+ const result = sanitizeDisplayName(' John Doe ');
+ expect(result).toBe('John Doe');
+ });
+ });
+
+ describe('sanitizeBio', () => {
+ it('should sanitize bio text', () => {
+ const result = sanitizeBio('Hello
World');
+ expect(result).toBe('Hello World');
+ });
+
+ it('should preserve newlines', () => {
+ const result = sanitizeBio('Line1\n\nLine2');
+ expect(result).toContain('\n');
+ });
+
+ it('should limit multiple newlines', () => {
+ const result = sanitizeBio('Line1\n\n\n\nLine2');
+ expect(result).not.toContain('\n\n\n');
+ });
+ });
+
+ describe('sanitizeUsername', () => {
+ it('should sanitize username', () => {
+ const result = sanitizeUsername('user@name',
+ number: 123,
+ };
+
+ const result = sanitizeObject(obj);
+ expect(result.name).toBe('John');
+ expect(result.bio).toBe('Hello');
+ expect(result.number).toBe(123);
+ });
+
+ it('should sanitize nested objects', () => {
+ const obj = {
+ user: {
+ name: 'John',
+ },
+ };
+
+ const result = sanitizeObject(obj);
+ expect(result.user.name).toBe('John');
+ });
+
+ it('should sanitize arrays', () => {
+ const arr = ['Hello', 'World'];
+ const result = sanitizeObject(arr);
+ expect(result[0]).toBe('Hello');
+ expect(result[1]).toBe('World');
+ });
+ });
+
+ describe('sanitizeFormData', () => {
+ it('should sanitize form data with field config', () => {
+ const formData = {
+ display_name: 'John',
+ bio: 'Hello World',
+ profile_picture_url: 'https://example.com/image.jpg',
+ };
+
+ const result = sanitizeFormData(formData, {
+ display_name: { type: 'display_name' },
+ bio: { type: 'bio' },
+ profile_picture_url: { type: 'url' },
+ });
+
+ expect(result.display_name).toBe('John');
+ expect(result.bio).toBe('Hello World');
+ expect(result.profile_picture_url).toBeTruthy();
+ });
+
+ it('should use default sanitization for fields without config', () => {
+ const formData = {
+ unknown_field: '',
+ };
+
+ const result = sanitizeFormData(formData);
+ expect(result.unknown_field).toBe('alert(1)');
+ });
+ });
+
+ describe('checkDangerousContent', () => {
+ it('should detect HTML tags', () => {
+ const result = checkDangerousContent('Hello
');
+ expect(result.isSafe).toBe(false);
+ expect(result.warnings).toContain('Contains HTML tags');
+ });
+
+ it('should detect script tags', () => {
+ const result = checkDangerousContent('');
+ expect(result.isSafe).toBe(false);
+ expect(result.warnings).toContain('Contains script tags');
+ });
+
+ it('should detect javascript: protocol', () => {
+ const result = checkDangerousContent('javascript:alert(1)');
+ expect(result.isSafe).toBe(false);
+ expect(result.warnings).toContain('Contains javascript: protocol');
+ });
+
+ it('should detect event handlers', () => {
+ const result = checkDangerousContent('onclick=evil()');
+ expect(result.isSafe).toBe(false);
+ expect(result.warnings).toContain('Contains event handlers');
+ });
+
+ it('should return safe for clean content', () => {
+ const result = checkDangerousContent('Hello World');
+ expect(result.isSafe).toBe(true);
+ expect(result.warnings).toHaveLength(0);
+ });
+
+ it('should detect multiple issues', () => {
+ const result = checkDangerousContent('javascript:evil()');
+ expect(result.isSafe).toBe(false);
+ expect(result.warnings.length).toBeGreaterThan(1);
+ });
+ });
+});
+
diff --git a/apps/web/lib/utils/sanitization.js b/apps/web/lib/utils/sanitization.js
new file mode 100644
index 0000000..3ca5e35
--- /dev/null
+++ b/apps/web/lib/utils/sanitization.js
@@ -0,0 +1,505 @@
+/**
+ * Input Sanitization Utilities
+ *
+ * Utility functions for sanitizing user inputs to prevent XSS attacks,
+ * handle unicode properly, and normalize text data.
+ *
+ * Features:
+ * - Strip HTML tags from text inputs
+ * - Remove dangerous characters
+ * - Normalize whitespace
+ * - Trim leading/trailing spaces
+ * - Escape special characters where needed
+ * - Handle unicode characters properly
+ * - Apply to all user-generated content
+ */
+
+/**
+ * Strip HTML tags from a string
+ * Removes all HTML/XML tags while preserving text content
+ *
+ * @param {string} input - String to sanitize
+ * @returns {string} Text with HTML tags removed
+ */
+export function stripHtmlTags(input) {
+ if (typeof input !== 'string') {
+ return String(input);
+ }
+
+ // Remove HTML tags using regex
+ // This regex matches < followed by any characters until >
+ // The non-greedy ? ensures we match the shortest possible tag
+ return input.replace(/<[^>]*>/g, '');
+}
+
+/**
+ * Remove dangerous characters from a string
+ * Removes characters that could be used for XSS or injection attacks
+ *
+ * @param {string} input - String to sanitize
+ * @returns {string} String with dangerous characters removed
+ */
+export function removeDangerousChars(input) {
+ if (typeof input !== 'string') {
+ return String(input);
+ }
+
+ return input
+ .replace(/[<>]/g, '') // Remove < and >
+ .replace(/javascript:/gi, '') // Remove javascript: protocol
+ .replace(/on\w+\s*=/gi, '') // Remove event handlers (onclick=, onload=, etc.)
+ .replace(/data:/gi, '') // Remove data: protocol (can be dangerous)
+ .replace(/vbscript:/gi, '') // Remove vbscript: protocol
+ .replace(/expression\s*\(/gi, '') // Remove CSS expressions
+ .replace(/import\s+/gi, '') // Remove import statements
+ .replace(/@import/gi, '') // Remove CSS @import
+ .replace(/url\s*\(/gi, ''); // Remove url() in CSS
+}
+
+/**
+ * Normalize whitespace in a string
+ * Converts multiple spaces, tabs, newlines to single spaces
+ *
+ * @param {string} input - String to normalize
+ * @returns {string} String with normalized whitespace
+ */
+export function normalizeWhitespace(input) {
+ if (typeof input !== 'string') {
+ return String(input);
+ }
+
+ return input
+ .replace(/\s+/g, ' ') // Replace multiple whitespace with single space
+ .replace(/\n\s*\n/g, '\n') // Replace multiple newlines with single newline
+ .replace(/[\t\r]/g, ' '); // Replace tabs and carriage returns with spaces
+}
+
+/**
+ * Trim leading and trailing whitespace
+ *
+ * @param {string} input - String to trim
+ * @returns {string} Trimmed string
+ */
+export function trimWhitespace(input) {
+ if (typeof input !== 'string') {
+ return String(input);
+ }
+
+ return input.trim();
+}
+
+/**
+ * Escape HTML special characters
+ * Converts characters that have special meaning in HTML to their entities
+ *
+ * @param {string} input - String to escape
+ * @returns {string} Escaped string
+ */
+export function escapeHtml(input) {
+ if (typeof input !== 'string') {
+ return String(input);
+ }
+
+ const htmlEscapes = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ '/': '/',
+ };
+
+ return input.replace(/[&<>"'/]/g, (char) => htmlEscapes[char]);
+}
+
+/**
+ * Unescape HTML entities
+ * Converts HTML entities back to their characters
+ *
+ * @param {string} input - String to unescape
+ * @returns {string} Unescaped string
+ */
+export function unescapeHtml(input) {
+ if (typeof input !== 'string') {
+ return String(input);
+ }
+
+ const htmlUnescapes = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ ''': "'",
+ ''': "'",
+ '/': '/',
+ '/': '/',
+ };
+
+ return input.replace(/&(amp|lt|gt|quot|#x27|#39|#x2F|#47);/g, (match) => {
+ return htmlUnescapes[match] || match;
+ });
+}
+
+/**
+ * Normalize unicode characters
+ * Handles unicode normalization and removes problematic characters
+ *
+ * @param {string} input - String to normalize
+ * @param {string} form - Unicode normalization form (NFC, NFD, NFKC, NFKD)
+ * @returns {string} Unicode-normalized string
+ */
+export function normalizeUnicode(input, form = 'NFC') {
+ if (typeof input !== 'string') {
+ return String(input);
+ }
+
+ try {
+ // Normalize unicode (NFC is the most common form)
+ // NFC: Canonical Decomposition, followed by Canonical Composition
+ let normalized = input.normalize(form);
+
+ // Remove zero-width characters that could be used for homograph attacks
+ normalized = normalized.replace(/[\u200B-\u200D\uFEFF]/g, '');
+
+ // Remove other invisible/control characters (but keep newlines)
+ normalized = normalized.replace(/[\u0000-\u0008\u000B-\u000C\u000E-\u001F\u007F-\u009F]/g, '');
+
+ return normalized;
+ } catch (error) {
+ // If normalization fails, return original string
+ console.warn('[sanitization] Unicode normalization failed:', error);
+ return input;
+ }
+}
+
+/**
+ * Sanitize text input (general purpose)
+ * Applies all sanitization steps for general text input
+ *
+ * @param {string} input - String to sanitize
+ * @param {Object} options - Sanitization options
+ * @param {boolean} options.stripHtml - Strip HTML tags (default: true)
+ * @param {boolean} options.removeDangerous - Remove dangerous chars (default: true)
+ * @param {boolean} options.normalizeWhitespace - Normalize whitespace (default: true)
+ * @param {boolean} options.trim - Trim whitespace (default: true)
+ * @param {boolean} options.escapeHtml - Escape HTML (default: false, usually not needed if stripping)
+ * @param {boolean} options.normalizeUnicode - Normalize unicode (default: true)
+ * @returns {string} Sanitized string
+ */
+export function sanitizeText(input, options = {}) {
+ if (input === null || input === undefined) {
+ return '';
+ }
+
+ if (typeof input !== 'string') {
+ input = String(input);
+ }
+
+ const {
+ stripHtml = true,
+ removeDangerous = true,
+ normalizeWhitespace: normalizeWS = true,
+ trim = true,
+ escapeHtml: escape = false,
+ normalizeUnicode: normalizeUni = true,
+ } = options;
+
+ let sanitized = input;
+
+ // Normalize unicode first (before other operations)
+ if (normalizeUni) {
+ sanitized = normalizeUnicode(sanitized);
+ }
+
+ // Strip HTML tags
+ if (stripHtml) {
+ sanitized = stripHtmlTags(sanitized);
+ }
+
+ // Remove dangerous characters
+ if (removeDangerous) {
+ sanitized = removeDangerousChars(sanitized);
+ }
+
+ // Normalize whitespace
+ if (normalizeWS) {
+ sanitized = normalizeWhitespace(sanitized);
+ }
+
+ // Trim whitespace
+ if (trim) {
+ sanitized = trimWhitespace(sanitized);
+ }
+
+ // Escape HTML (usually not needed if we're stripping HTML)
+ // This is useful if you want to preserve HTML but escape it
+ if (escape) {
+ sanitized = escapeHtml(sanitized);
+ }
+
+ return sanitized;
+}
+
+/**
+ * Sanitize display name
+ * Applies sanitization appropriate for display names
+ *
+ * @param {string} input - Display name to sanitize
+ * @returns {string} Sanitized display name
+ */
+export function sanitizeDisplayName(input) {
+ return sanitizeText(input, {
+ stripHtml: true,
+ removeDangerous: true,
+ normalizeWhitespace: true,
+ trim: true,
+ escapeHtml: false,
+ normalizeUnicode: true,
+ });
+}
+
+/**
+ * Sanitize bio/description text
+ * Allows newlines and preserves some formatting
+ *
+ * @param {string} input - Bio text to sanitize
+ * @returns {string} Sanitized bio text
+ */
+export function sanitizeBio(input) {
+ if (typeof input !== 'string') {
+ return String(input || '');
+ }
+
+ // Normalize unicode
+ let sanitized = normalizeUnicode(input);
+
+ // Strip HTML but preserve newlines
+ sanitized = stripHtmlTags(sanitized);
+
+ // Remove dangerous characters
+ sanitized = removeDangerousChars(sanitized);
+
+ // Normalize whitespace but preserve newlines
+ sanitized = sanitized.replace(/[ \t]+/g, ' '); // Multiple spaces/tabs -> single space
+ sanitized = sanitized.replace(/\n{3,}/g, '\n\n'); // More than 2 newlines -> 2 newlines
+
+ // Trim
+ sanitized = trimWhitespace(sanitized);
+
+ return sanitized;
+}
+
+/**
+ * Sanitize username
+ * More strict sanitization for usernames
+ *
+ * @param {string} input - Username to sanitize
+ * @returns {string} Sanitized username
+ */
+export function sanitizeUsername(input) {
+ if (typeof input !== 'string') {
+ return String(input || '');
+ }
+
+ // Normalize unicode
+ let sanitized = normalizeUnicode(input);
+
+ // Strip HTML
+ sanitized = stripHtmlTags(sanitized);
+
+ // Remove dangerous characters
+ sanitized = removeDangerousChars(sanitized);
+
+ // Remove special characters that shouldn't be in usernames
+ // Keep: letters, numbers, underscores, hyphens, dots
+ sanitized = sanitized.replace(/[^a-zA-Z0-9_.-]/g, '');
+
+ // Normalize whitespace (remove all)
+ sanitized = sanitized.replace(/\s+/g, '');
+
+ // Trim
+ sanitized = trimWhitespace(sanitized);
+
+ return sanitized;
+}
+
+/**
+ * Sanitize URL input
+ * Validates and sanitizes URL strings
+ *
+ * @param {string} input - URL to sanitize
+ * @returns {string|null} Sanitized URL or null if invalid
+ */
+export function sanitizeUrl(input) {
+ if (typeof input !== 'string' || !input.trim()) {
+ return null;
+ }
+
+ let sanitized = trimWhitespace(input);
+
+ // Remove dangerous protocols
+ const dangerousProtocols = ['javascript:', 'data:', 'vbscript:', 'file:'];
+ const lowerInput = sanitized.toLowerCase();
+
+ for (const protocol of dangerousProtocols) {
+ if (lowerInput.startsWith(protocol)) {
+ return null;
+ }
+ }
+
+ // Only allow http, https, and relative URLs
+ // Relative URLs start with / or ./
+ const isRelative = sanitized.startsWith('/') || sanitized.startsWith('./');
+ const isHttp = sanitized.match(/^https?:\/\//i);
+
+ if (!isRelative && !isHttp) {
+ return null;
+ }
+
+ // Validate URL format (if not relative)
+ if (!isRelative) {
+ try {
+ const url = new URL(sanitized);
+ // Only allow http and https
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ return null;
+ }
+ return url.toString();
+ } catch {
+ return null;
+ }
+ }
+
+ return sanitized;
+}
+
+/**
+ * Sanitize object recursively
+ * Applies sanitization to all string values in an object
+ *
+ * @param {any} input - Object or value to sanitize
+ * @param {Object} options - Sanitization options
+ * @param {Function} options.sanitizer - Custom sanitizer function per field type
+ * @returns {any} Sanitized object
+ */
+export function sanitizeObject(input, options = {}) {
+ const { sanitizer = sanitizeText } = options;
+
+ if (input === null || input === undefined) {
+ return input;
+ }
+
+ if (typeof input === 'string') {
+ return sanitizer(input, options);
+ }
+
+ if (typeof input === 'number' || typeof input === 'boolean') {
+ return input;
+ }
+
+ if (Array.isArray(input)) {
+ return input.map(item => sanitizeObject(item, options));
+ }
+
+ if (typeof input === 'object') {
+ const sanitized = {};
+ for (const [key, value] of Object.entries(input)) {
+ // Sanitize key as well
+ const sanitizedKey = sanitizeText(String(key), {
+ stripHtml: true,
+ removeDangerous: true,
+ normalizeWhitespace: false,
+ trim: true,
+ });
+
+ sanitized[sanitizedKey] = sanitizeObject(value, options);
+ }
+ return sanitized;
+ }
+
+ return input;
+}
+
+/**
+ * Sanitize form data
+ * Applies appropriate sanitization based on field type
+ *
+ * @param {Object} formData - Form data object
+ * @param {Object} fieldConfig - Configuration for each field's sanitization
+ * @returns {Object} Sanitized form data
+ */
+export function sanitizeFormData(formData, fieldConfig = {}) {
+ const sanitized = {};
+
+ for (const [field, value] of Object.entries(formData)) {
+ const config = fieldConfig[field];
+
+ if (config?.type === 'display_name') {
+ sanitized[field] = sanitizeDisplayName(value);
+ } else if (config?.type === 'bio') {
+ sanitized[field] = sanitizeBio(value);
+ } else if (config?.type === 'username') {
+ sanitized[field] = sanitizeUsername(value);
+ } else if (config?.type === 'url') {
+ sanitized[field] = sanitizeUrl(value);
+ } else if (config?.sanitizer) {
+ sanitized[field] = config.sanitizer(value);
+ } else {
+ // Default sanitization
+ sanitized[field] = sanitizeText(value);
+ }
+ }
+
+ return sanitized;
+}
+
+/**
+ * Check if a string contains potentially dangerous content
+ *
+ * @param {string} input - String to check
+ * @returns {Object} { isSafe: boolean, warnings: string[] }
+ */
+export function checkDangerousContent(input) {
+ if (typeof input !== 'string') {
+ return { isSafe: true, warnings: [] };
+ }
+
+ const warnings = [];
+
+ // Check for HTML tags
+ if (/<[^>]*>/g.test(input)) {
+ warnings.push('Contains HTML tags');
+ }
+
+ // Check for script tags
+ if (/ and removes everything between the tags
+ output = output.replace(/ is fully removed before we strip < and >
+ output = output.replace(/, is fully removed before we strip < and >
- output = output.replace(/, , script>, etc.
+ // This must happen before other tag removal to prevent script execution
+ output = output.replace(/<\s*script\b[^>]*>[\s\S]*?<\s*\/\s*script\s*>/gi, '');
+ output = output.replace(/<\s*script\b[^>]*(\/>|>)/gi, '');
+ output = output.replace(/<\/\s*script\s*>/gi, '');
+
+ // --- Remove dangerous protocols ---
+ // Remove protocol prefix only, keep the rest (for test compatibility)
+ output = output.replace(/\b(?:javascript|vbscript|file|data):/gi, '');
+
+ // --- Remove inline event handlers ---
+ // Remove only the handler part (onclick=), preserve the value
+ // Matches: onclick="...", onclick='...', onclick=..., onclick = "..."
+ // But we need to preserve the value, so we remove just the handler declaration
output = output.replace(/\bon\w+\s*=\s*/gi, '');
- // Remove < and > characters (prevent tag injection) - do this AFTER removing script tags
- // This ensures script tags are removed first, then any remaining angle brackets
- output = output.replace(/[<>]/g, '');
-
- // Remove script tag remnants and leftover "script" text that may remain
- // Handles cases like "text/html,script" -> "text/html," after angle brackets are removed
- // This regex matches ",script" or "script" when it's clearly a leftover from tag removal
- output = output.replace(/,script\b/gi, ',');
- output = output.replace(/\bscript>/gi, '');
-
- // Remove dangerous CSS expressions
- output = output.replace(/expression\s*\(/gi, '');
-
- // Remove import statements
- output = output.replace(/import\s+/gi, '');
- output = output.replace(/@import\s+/gi, '');
-
- // Remove url() in CSS (can contain javascript:)
- output = output.replace(/url\s*\(/gi, '');
-
- // NOTE: We do NOT remove alert(), eval(), etc. function calls here
- // because they may be legitimate text content. The sanitization function
- // removeDangerousChars should only remove dangerous patterns like protocols
- // and event handlers, not function names themselves. If full sanitization
- // is needed (removing function calls), that should be done in sanitizeText()
- // which applies additional layers of sanitization.
-
- // Remove dangerous document/window methods and properties
+ // --- Remove CSS/DOM-based injection patterns ---
output = output
+ .replace(/expression\s*\(/gi, '(')
+ .replace(/url\s*\(/gi, '(')
+ .replace(/@import\s+/gi, '')
.replace(/document\.(write|writeln|cookie|location)/gi, '')
.replace(/window\.(location|document|eval|parent|top)/gi, '')
.replace(/\.innerHTML/gi, '')
.replace(/\.outerHTML/gi, '')
.replace(/\.insertAdjacentHTML/gi, '');
- // Remove common XSS payloads (img and svg tags with malicious attributes)
- output = output.replace(/
]*>/gi, '');
- output = output.replace(/