Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions controller/userPreferencesController.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,42 @@ const updateNotificationPreferences = async (req, res) => {
}
};



const getUiSettings = async (req, res) => {
try {
const response = await userPreferencesService.getUiSettings(
req.user.userId
);
return res.status(200).json(response);
} catch (error) {
return handleError(res, error, "Error fetching UI settings", {
userId: req.user?.userId,
});
}
};

const updateUiSettings = async (req, res) => {
try {
const response = await userPreferencesService.updateUiSettings(
req.user.userId,
req.body.ui_settings || {}
);
return res.status(200).json(response);
} catch (error) {
return handleError(res, error, "Error updating UI settings", {
userId: req.user?.userId,
});
}
};

module.exports = {
getUserPreferences,
postUserPreferences,
getExtendedUserPreferences,
updateExtendedUserPreferences,
getNotificationPreferences,
updateNotificationPreferences,
getUiSettings,
updateUiSettings,
};
276 changes: 156 additions & 120 deletions model/updateUserPreferences.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,42 @@
const supabase = require("../dbConnection.js");
const { EMPTY_HEALTH_CONTEXT, saveUserPreferenceState } = require("./userPreferenceState");
const { ServiceError } = require("../services/serviceError");
const fetchUserPreferences = require("./fetchUserPreferences");

function listFromHealthContext(items = []) {
return (Array.isArray(items) ? items : [])
.map((item) => {
if (Number.isInteger(item)) return item;
if (item && Number.isInteger(item.referenceId)) return item.referenceId;
if (item && Number.isInteger(item.id)) return item.id;
return null;
})
.filter(Number.isInteger);
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────

function hasOwnProperty(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}

function hasAnyOwnProperty(object, keys = []) {
return keys.some((key) => hasOwnProperty(object, key));
}

function extractPreferenceId(value) {
if (Number.isInteger(value) && value > 0) return value;
if (value && typeof value === "object") {
for (const candidate of [value.referenceId, value.id]) {
const parsed = Number(candidate);
if (Number.isInteger(parsed) && parsed > 0) return parsed;
}
}
return null;
}

function normalizePreferenceIds(values = []) {
if (!Array.isArray(values)) return [];
return [...new Set(
values.map(extractPreferenceId).filter((v) => Number.isInteger(v) && v > 0)
)];
}

function getFoodPreferenceSource(body = {}) {
return body.food_preferences && typeof body.food_preferences === "object"
? body.food_preferences
: body;
}

function normalizeHealthContext(healthContext = {}) {
Expand All @@ -23,9 +49,9 @@ function normalizeHealthContext(healthContext = {}) {

function normalizeUiSettings(settings = {}) {
return {
language: settings.language || 'en',
theme: settings.theme || 'light',
font_size: settings.font_size || '16px'
language: settings.language || "en",
theme: settings.theme || "light",
font_size: settings.font_size || "16px"
};
}

Expand All @@ -39,53 +65,42 @@ function normalizeNotificationPreferences(preferences = {}) {
};
}

function normalizePreferenceIds(values = []) {
if (!Array.isArray(values)) {
return [];
}

return [...new Set(values
.map((value) => Number(value))
.filter((value) => Number.isInteger(value) && value > 0))];
}

function hasOwnProperty(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}
// ─────────────────────────────────────────────────────────────────────────────
// Join table config — single source of truth for all food preference groups
// ─────────────────────────────────────────────────────────────────────────────

const PREFERENCE_TABLES = [
{ table: "user_dietary_requirements", foreignKey: "dietary_requirement_id", key: "dietary_requirements" },
{ table: "user_allergies", foreignKey: "allergy_id", key: "allergies" },
{ table: "user_cuisines", foreignKey: "cuisine_id", key: "cuisines" },
{ table: "user_dislikes", foreignKey: "dislike_id", key: "dislikes" },
{ table: "user_health_conditions", foreignKey: "health_condition_id", key: "health_conditions" },
{ table: "user_spice_levels", foreignKey: "spice_level_id", key: "spice_levels" },
{ table: "user_cooking_methods", foreignKey: "cooking_method_id", key: "cooking_methods" }
{ table: "user_allergies", foreignKey: "allergy_id", key: "allergies" },
{ table: "user_cuisines", foreignKey: "cuisine_id", key: "cuisines" },
{ table: "user_dislikes", foreignKey: "dislike_id", key: "dislikes" },
{ table: "user_health_conditions", foreignKey: "health_condition_id", key: "health_conditions" },
{ table: "user_spice_levels", foreignKey: "spice_level_id", key: "spice_levels" },
{ table: "user_cooking_methods", foreignKey: "cooking_method_id", key: "cooking_methods" },
];

const FOOD_PREFERENCE_KEYS = PREFERENCE_TABLES.map(({ key }) => key);

// ─────────────────────────────────────────────────────────────────────────────
// Join table helpers
// ─────────────────────────────────────────────────────────────────────────────

async function replaceJoinTable(table, userId, foreignKey, values = []) {
const { error: deleteError } = await supabase
.from(table)
.delete()
.eq("user_id", userId);

if (deleteError) {
throw deleteError;
}

if (!values.length) {
return;
}
if (deleteError) throw deleteError;
if (!values.length) return;

const records = values.map((value) => ({
user_id: userId,
[foreignKey]: value
}));

const { error: insertError } = await supabase.from(table).insert(records);
if (insertError) {
throw insertError;
}
if (insertError) throw insertError;
}

async function replaceUserPreferencesFallback(userId, preferenceGroups) {
Expand All @@ -106,13 +121,12 @@ async function replaceUserPreferencesTransaction(userId, preferenceGroups) {
p_cooking_methods: preferenceGroups.cooking_methods
});

if (!error) {
return;
}
if (!error) return;

const rpcMissing = error.code === "PGRST202"
|| error.code === "42883"
|| /replace_user_preferences/i.test(error.message || "");
const rpcMissing =
error.code === "PGRST202" ||
error.code === "42883" ||
/replace_user_preferences/i.test(error.message || "");

if (rpcMissing) {
await replaceUserPreferencesFallback(userId, preferenceGroups);
Expand All @@ -122,86 +136,108 @@ async function replaceUserPreferencesTransaction(userId, preferenceGroups) {
throw error;
}

// ─────────────────────────────────────────────────────────────────────────────
// Main export
// ─────────────────────────────────────────────────────────────────────────────

async function updateUserPreferences(userId, body = {}) {
try {
const normalizedUserId = Number(userId);
if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
throw new ServiceError(400, 'User ID must be a positive integer');
}
const normalizedUserId = Number(userId);
if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
throw new ServiceError(400, "User ID must be a positive integer");
}

const healthContext = normalizeHealthContext(body.health_context);

const dietaryRequirements = Array.isArray(body.dietary_requirements) ? body.dietary_requirements : [];
const allergies = Array.isArray(body.allergies) ? body.allergies : listFromHealthContext(healthContext.allergies);
const cuisines = Array.isArray(body.cuisines) ? body.cuisines : [];
const dislikes = Array.isArray(body.dislikes) ? body.dislikes : [];
const healthConditions = Array.isArray(body.health_conditions) ? body.health_conditions : listFromHealthContext(healthContext.chronic_conditions);
const spiceLevels = Array.isArray(body.spice_levels) ? body.spice_levels : [];
const cookingMethods = Array.isArray(body.cooking_methods) ? body.cooking_methods : [];

const shouldUpdateJoinTables = [
'dietary_requirements',
'allergies',
'cuisines',
'dislikes',
'health_conditions',
'spice_levels',
'cooking_methods'
].some((key) => body[key] !== undefined) || body.health_context !== undefined;

if (
!body.health_context
&& !body.notification_preferences
&& !body.ui_settings
&& ![
'dietary_requirements',
'allergies',
'cuisines',
'dislikes',
'health_conditions',
'spice_levels',
'cooking_methods'
].every((key) => hasOwnProperty(body, key))
) {
throw new ServiceError(
400,
'All preference groups are required: dietary_requirements, allergies, cuisines, dislikes, health_conditions, spice_levels, cooking_methods'
);
// Support both canonical nested payload { food_preferences: { ... } }
// and legacy flat payload { dietary_requirements: [], ... }
const foodSource = getFoodPreferenceSource(body);

const incomingHealthContext =
body.health_context && typeof body.health_context === "object"
? body.health_context
: undefined;

// Detect which food preference groups were explicitly provided
// using FOOD_PREFERENCE_KEYS so allergies and health_conditions are never missed
const foodGroupUpdates = {};
for (const key of FOOD_PREFERENCE_KEYS) {
if (hasOwnProperty(foodSource, key)) {
foodGroupUpdates[key] = normalizePreferenceIds(foodSource[key]);
}
}

if (shouldUpdateJoinTables) {
await replaceUserPreferencesTransaction(normalizedUserId, {
dietary_requirements: normalizePreferenceIds(dietaryRequirements),
allergies: normalizePreferenceIds(allergies),
cuisines: normalizePreferenceIds(cuisines),
dislikes: normalizePreferenceIds(dislikes),
health_conditions: normalizePreferenceIds(healthConditions),
spice_levels: normalizePreferenceIds(spiceLevels),
cooking_methods: normalizePreferenceIds(cookingMethods)
});
}
const hasFoodGroupUpdates = Object.keys(foodGroupUpdates).length > 0;
const hasHealthContextUpdate = hasOwnProperty(body, "health_context");
const hasNotificationUpdate = hasOwnProperty(body, "notification_preferences");
const hasUiSettingsUpdate = hasOwnProperty(body, "ui_settings");

const hasAnySupportedUpdate =
hasFoodGroupUpdates ||
hasHealthContextUpdate ||
hasNotificationUpdate ||
hasUiSettingsUpdate;

if (!hasAnySupportedUpdate) {
throw new ServiceError(400, "No supported preference fields were provided");
}

if (
body.health_context !== undefined ||
body.notification_preferences !== undefined ||
body.ui_settings !== undefined
) {
await saveUserPreferenceState(normalizedUserId, (current) => ({
...current,
health_context: body.health_context !== undefined
? normalizeHealthContext(body.health_context)
: current.health_context || EMPTY_HEALTH_CONTEXT,
notification_preferences: body.notification_preferences !== undefined
? normalizeNotificationPreferences(body.notification_preferences)
: current.notification_preferences || {},
ui_settings: body.ui_settings !== undefined
? normalizeUiSettings(body.ui_settings)
: current.ui_settings || {}
}));
// Fetch current preferences so we can preserve unmodified groups
const current = await fetchUserPreferences(normalizedUserId);

// ── Join tables ────────────────────────────────────────────────────────────
if (hasFoodGroupUpdates) {
// Build the full set of groups — use incoming value if provided, else keep current
const nextGroups = {};
for (const key of FOOD_PREFERENCE_KEYS) {
nextGroups[key] = hasOwnProperty(foodGroupUpdates, key)
? foodGroupUpdates[key]
: normalizePreferenceIds(current[key]);
}
} catch (error) {
throw error;

await replaceUserPreferencesTransaction(normalizedUserId, nextGroups);
}

// ── Preference state (health_context, notifications, ui_settings) ──────────
if (hasHealthContextUpdate || hasNotificationUpdate || hasUiSettingsUpdate) {
await saveUserPreferenceState(normalizedUserId, (stored) => {
const currentHealthContext = stored.health_context || EMPTY_HEALTH_CONTEXT;

const nextHealthContext = hasHealthContextUpdate
? {
allergies: incomingHealthContext && hasOwnProperty(incomingHealthContext, "allergies")
? normalizeHealthContext(incomingHealthContext).allergies
: currentHealthContext.allergies || [],
chronic_conditions: incomingHealthContext && hasOwnProperty(incomingHealthContext, "chronic_conditions")
? normalizeHealthContext(incomingHealthContext).chronic_conditions
: currentHealthContext.chronic_conditions || [],
medications: incomingHealthContext && hasOwnProperty(incomingHealthContext, "medications")
? normalizeHealthContext(incomingHealthContext).medications
: currentHealthContext.medications || [],
}
: currentHealthContext;

const nextNotifications = hasNotificationUpdate
? {
...(stored.notification_preferences || {}),
...normalizeNotificationPreferences(body.notification_preferences)
}
: (stored.notification_preferences || {});

const nextUiSettings = hasUiSettingsUpdate
? {
...(stored.ui_settings || {}),
...body.ui_settings
}
: (stored.ui_settings || {});

return {
...stored,
health_context: nextHealthContext,
notification_preferences: nextNotifications,
ui_settings: nextUiSettings,
};
});
}

return fetchUserPreferences(normalizedUserId);
}

module.exports = updateUserPreferences;
Loading
Loading