diff --git a/backend/routes/adminRoutes.js b/backend/routes/adminRoutes.js index e96a7e50..13fe7aaa 100644 --- a/backend/routes/adminRoutes.js +++ b/backend/routes/adminRoutes.js @@ -282,6 +282,12 @@ router.get('/admin/user/:userId/analytics', verifyToken, requireAdmin, async (re }); const getGlobalModels = require('../services/getGlobalModelService'); +const { + SIGNUP_OPTIONS, + TENANT_TEMPLATE_LIBRARY, + sanitizePlatformOnboardingConfig, + getPlatformOnboardingConfig, +} = require('../services/onboardingConfigService'); /** * GET /admin/platform-admins – list platform admins (GlobalUsers with platform_admin role) @@ -512,6 +518,71 @@ router.put('/admin/tenant-config', verifyToken, requireAdmin, async (req, res) = } }); +router.get('/admin/platform-onboarding-config', verifyToken, requireAdmin, async (req, res) => { + if (!req.user.platformRoles?.includes('platform_admin')) { + return res.status(403).json({ success: false, message: 'Platform admin required.' }); + } + try { + const { TenantConfig } = getGlobalModels(req, 'TenantConfig'); + const doc = await TenantConfig.findOne({ configKey: 'default' }).lean(); + const config = getPlatformOnboardingConfig(doc?.onboardingConfig || null); + res.json({ + success: true, + data: { + config, + meta: { + signupOptions: SIGNUP_OPTIONS, + }, + }, + }); + } catch (err) { + console.error('GET /admin/platform-onboarding-config failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.put('/admin/platform-onboarding-config', verifyToken, requireAdmin, async (req, res) => { + if (!req.user.platformRoles?.includes('platform_admin')) { + return res.status(403).json({ success: false, message: 'Platform admin required.' }); + } + try { + const incoming = req.body?.config; + if (!incoming || typeof incoming !== 'object') { + return res.status(400).json({ success: false, message: 'config object is required.' }); + } + const config = sanitizePlatformOnboardingConfig(incoming); + const { TenantConfig } = getGlobalModels(req, 'TenantConfig'); + const updatedBy = req.user.globalUserId || req.user.userId || null; + const doc = await TenantConfig.findOneAndUpdate( + { configKey: 'default' }, + { $set: { onboardingConfig: config, updatedBy } }, + { new: true, upsert: true, setDefaultsOnInsert: true } + ).lean(); + res.json({ + success: true, + data: { + config: getPlatformOnboardingConfig(doc?.onboardingConfig || null), + updatedAt: doc?.updatedAt || null, + }, + }); + } catch (err) { + console.error('PUT /admin/platform-onboarding-config failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.get('/admin/tenant-onboarding-template-library', verifyToken, requireAdmin, async (req, res) => { + try { + res.json({ + success: true, + data: TENANT_TEMPLATE_LIBRARY, + }); + } catch (err) { + console.error('GET /admin/tenant-onboarding-template-library failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + /** * DELETE /admin/platform-admins/:globalUserId – remove platform_admin role */ diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index 2dd242ec..d060f32a 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -1,6 +1,8 @@ const express = require('express'); const { verifyToken, verifyTokenOptional, authorizeRoles } = require('../middlewares/verifyToken'); const { requireAdmin } = require('../middlewares/requireAdmin'); +const getGlobalModels = require('../services/getGlobalModelService'); +const mongoose = require('mongoose'); const cron = require('node-cron'); const axios = require('axios'); const { isProfane } = require('../services/profanityFilterService'); @@ -13,6 +15,13 @@ const { uploadImageToS3, deleteAndUploadImageToS3 } = require('../services/image const { sendRoomCheckinEvent } = require('../inngest/events'); const multer = require('multer'); const path = require('path'); +const { + sanitizeTenantOnboardingConfig, + getPlatformOnboardingConfig, + detectSignupOption, + resolveOnboardingSteps, + TENANT_TEMPLATE_LIBRARY, +} = require('../services/onboardingConfigService'); const upload = multer({ storage: multer.memoryStorage(), @@ -24,6 +33,371 @@ const upload = multer({ const router = express.Router(); +async function loadResolvedOnboardingConfig(req, user) { + const { TenantConfig } = getGlobalModels(req, 'TenantConfig'); + const { TenantOnboardingConfig } = getModels(req, 'TenantOnboardingConfig'); + const [platformDoc, tenantDoc] = await Promise.all([ + TenantConfig.findOne({ configKey: 'default' }).lean(), + TenantOnboardingConfig.findOne({ configKey: 'default' }).lean(), + ]); + const platformConfig = getPlatformOnboardingConfig(platformDoc?.onboardingConfig || null); + const tenantConfig = sanitizeTenantOnboardingConfig(tenantDoc || null); + const signupOption = detectSignupOption(user); + const steps = resolveOnboardingSteps(platformConfig, tenantConfig, signupOption); + return { + steps, + signupOption, + tenantConfig, + platformConfig, + }; +} + +async function applyOnboardingTemplateEffects(req, userId, templateSelections = {}) { + const normalized = templateSelections && typeof templateSelections === 'object' ? templateSelections : {}; + const selectedOrgIds = Array.isArray(normalized.follow_orgs) ? normalized.follow_orgs : []; + const selectedFriendUserIds = Array.isArray(normalized.add_friends) ? normalized.add_friends : []; + const validOrgIds = selectedOrgIds.filter((id) => mongoose.Types.ObjectId.isValid(id)); + const validFriendIdsInput = selectedFriendUserIds.filter((id) => mongoose.Types.ObjectId.isValid(id)); + + if (validOrgIds.length > 0) { + const { Org, OrgFollower } = getModels(req, 'Org', 'OrgFollower'); + const existing = await OrgFollower.find({ + user_id: userId, + org_id: { $in: validOrgIds }, + }).select('org_id').lean(); + const existingSet = new Set(existing.map((row) => String(row.org_id))); + const validOrgDocs = await Org.find({ _id: { $in: validOrgIds } }).select('_id').lean(); + const followersToCreate = validOrgDocs + .map((org) => String(org._id)) + .filter((orgId) => !existingSet.has(orgId)) + .map((orgId) => ({ + user_id: userId, + org_id: orgId, + })); + if (followersToCreate.length > 0) { + await OrgFollower.insertMany(followersToCreate, { ordered: false }); + } + } + + if (validFriendIdsInput.length > 0) { + const { User, Friendship } = getModels(req, 'User', 'Friendship'); + const candidateUsers = await User.find({ + _id: { $in: validFriendIdsInput }, + }).select('_id').lean(); + const validFriendIds = candidateUsers + .map((u) => String(u._id)) + .filter((id) => id !== String(userId)); + if (validFriendIds.length > 0) { + const existing = await Friendship.find({ + $or: [ + { requester: userId, recipient: { $in: validFriendIds } }, + { requester: { $in: validFriendIds }, recipient: userId }, + ], + }).select('requester recipient').lean(); + const existingPairs = new Set( + existing.map((row) => [String(row.requester), String(row.recipient)].sort().join(':')) + ); + const friendshipDocs = validFriendIds + .filter((friendId) => !existingPairs.has([String(userId), friendId].sort().join(':'))) + .map((friendId) => ({ + requester: userId, + recipient: friendId, + status: 'pending', + })); + if (friendshipDocs.length > 0) { + await Friendship.insertMany(friendshipDocs, { ordered: false }); + } + } + } +} + +function parseOnboardingResponses(rawResponses) { + if (!rawResponses) return {}; + if (typeof rawResponses === 'object') return rawResponses; + try { + const parsed = JSON.parse(rawResponses); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (_error) { + return {}; + } +} + +function validateStepResponse(step, value) { + const isEmpty = value == null || value === '' || (Array.isArray(value) && value.length === 0); + if (step.required && isEmpty) { + return { valid: false, message: `Step "${step.title}" is required.` }; + } + if (isEmpty) { + return { valid: true }; + } + + if (step.type === 'short_text' || step.type === 'long_text') { + if (typeof value !== 'string') { + return { valid: false, message: `Step "${step.title}" must be text.` }; + } + } + + if (step.type === 'number') { + const n = Number(value); + if (!Number.isFinite(n)) { + return { valid: false, message: `Step "${step.title}" must be a number.` }; + } + } + + if (step.type === 'single_select') { + const validValues = new Set((step.options || []).map((option) => option.value)); + if (typeof value !== 'string' || !validValues.has(value)) { + return { valid: false, message: `Step "${step.title}" has an invalid selection.` }; + } + } + + if (step.type === 'multi_select') { + const validValues = new Set((step.options || []).map((option) => option.value)); + if (!Array.isArray(value) || value.some((entry) => !validValues.has(entry))) { + return { valid: false, message: `Step "${step.title}" has invalid selections.` }; + } + if (step.maxSelections && value.length > step.maxSelections) { + return { valid: false, message: `Step "${step.title}" exceeds max selections.` }; + } + } + + if (step.type === 'template_follow_orgs' || step.type === 'template_add_friends') { + if (!Array.isArray(value)) { + return { valid: false, message: `Step "${step.title}" must be a list.` }; + } + } + + return { valid: true }; +} + +router.get('/onboarding-config', verifyToken, async (req, res) => { + const { User } = getModels(req, 'User'); + try { + const user = await User.findById(req.user.userId).lean(); + if (!user) { + return res.status(404).json({ success: false, message: 'User not found' }); + } + const resolved = await loadResolvedOnboardingConfig(req, user); + return res.json({ + success: true, + data: { + steps: resolved.steps, + signupOption: resolved.signupOption, + templateLibrary: TENANT_TEMPLATE_LIBRARY, + }, + }); + } catch (error) { + console.error('GET /onboarding-config failed', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + +router.get('/onboarding-profile', verifyToken, async (req, res) => { + const { User, Org, OrgFollower, Friendship } = getModels(req, 'User', 'Org', 'OrgFollower', 'Friendship'); + const type = String(req.query.type || '').trim().toLowerCase(); + const query = String(req.query.query || '').trim(); + const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 12, 1), 30); + try { + if (type === 'orgs') { + const regex = query ? new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') : null; + const orgQuery = regex ? { org_name: regex } : {}; + const orgs = await Org.find(orgQuery) + .select('_id org_name org_profile_image org_description') + .limit(limit) + .lean(); + const followed = await OrgFollower.find({ + user_id: req.user.userId, + org_id: { $in: orgs.map((org) => org._id) }, + }).select('org_id').lean(); + const followedSet = new Set(followed.map((row) => String(row.org_id))); + return res.json({ + success: true, + data: orgs.map((org) => ({ + _id: org._id, + name: org.org_name, + description: org.org_description, + picture: org.org_profile_image, + isFollowing: followedSet.has(String(org._id)), + })), + }); + } + + if (type === 'users') { + const userQuery = { _id: { $ne: req.user.userId } }; + if (query) { + const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + userQuery.$or = [{ username: regex }, { name: regex }]; + } + const users = await User.find(userQuery) + .select('_id username name picture') + .limit(limit) + .lean(); + const friendships = await Friendship.find({ + $or: [ + { requester: req.user.userId, recipient: { $in: users.map((u) => u._id) } }, + { requester: { $in: users.map((u) => u._id) }, recipient: req.user.userId }, + ], + }).select('requester recipient status').lean(); + const relationMap = new Map(); + friendships.forEach((row) => { + const other = String(row.requester) === String(req.user.userId) ? String(row.recipient) : String(row.requester); + let status = row.status || 'pending'; + if (status === 'pending' && String(row.requester) !== String(req.user.userId)) { + status = 'pending_inbound'; + } else if (status === 'pending') { + status = 'pending_outbound'; + } + relationMap.set(other, status); + }); + return res.json({ + success: true, + data: users.map((u) => ({ + _id: u._id, + username: u.username, + name: u.name, + picture: u.picture, + friendshipStatus: relationMap.get(String(u._id)) || 'none', + })), + }); + } + + return res.status(400).json({ success: false, message: 'type must be "orgs" or "users".' }); + } catch (error) { + console.error('GET /onboarding-profile failed', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + +router.post('/submit-onboarding', verifyToken, upload.single('picture'), async (req, res) => { + const { User } = getModels(req, 'User'); + try { + const user = await User.findById(req.user.userId); + if (!user) { + return res.status(404).json({ success: false, message: 'User not found' }); + } + const responses = parseOnboardingResponses(req.body?.responses); + const resolved = await loadResolvedOnboardingConfig(req, user); + const errors = []; + const normalizedResponses = {}; + const templateSelections = {}; + + resolved.steps.forEach((step) => { + const value = responses[step.key]; + const validation = validateStepResponse(step, value); + if (!validation.valid) { + errors.push(validation.message); + return; + } + if (value == null || value === '' || (Array.isArray(value) && value.length === 0)) { + return; + } + if (step.type === 'number') { + normalizedResponses[step.key] = Number(value); + } else { + normalizedResponses[step.key] = value; + } + if (step.type === 'template_follow_orgs' || step.type === 'template_add_friends') { + templateSelections[step.templateKey] = Array.isArray(value) ? value : []; + } + }); + + if (errors.length > 0) { + return res.status(400).json({ + success: false, + message: 'Onboarding submission has validation errors.', + errors, + }); + } + + const hasNameResponse = Object.prototype.hasOwnProperty.call(normalizedResponses, 'name'); + if (hasNameResponse && typeof normalizedResponses.name === 'string' && normalizedResponses.name.trim()) { + user.name = normalizedResponses.name.trim(); + } + + const hasUsernameResponse = Object.prototype.hasOwnProperty.call(normalizedResponses, 'username'); + if (hasUsernameResponse && typeof normalizedResponses.username === 'string' && normalizedResponses.username.trim()) { + user.username = normalizedResponses.username.trim(); + } + + if (req.file) { + const fileExtension = path.extname(req.file.originalname || '.png'); + const timestamp = Date.now(); + const fileName = `${req.user.userId}-${timestamp}${fileExtension}`; + if (user.picture) { + user.picture = await deleteAndUploadImageToS3(req.file, 'users', user.picture, fileName); + } else { + user.picture = await uploadImageToS3(req.file, 'users', fileName); + } + } + + user.onboardingResponses = normalizedResponses; + user.onboarded = true; + user.onboardingCompletedAt = new Date(); + + await applyOnboardingTemplateEffects(req, req.user.userId, templateSelections); + await user.save(); + + return res.status(200).json({ + success: true, + message: 'Onboarding completed successfully.', + data: { + onboarded: true, + onboardingResponses: normalizedResponses, + picture: user.picture || null, + }, + }); + } catch (error) { + console.error('POST /submit-onboarding failed', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + +router.get('/admin/tenant-onboarding-config', verifyToken, requireAdmin, async (req, res) => { + try { + const { TenantOnboardingConfig } = getModels(req, 'TenantOnboardingConfig'); + const doc = await TenantOnboardingConfig.findOne({ configKey: 'default' }).lean(); + const config = sanitizeTenantOnboardingConfig(doc || null); + return res.json({ + success: true, + data: { + config, + templateLibrary: TENANT_TEMPLATE_LIBRARY, + }, + }); + } catch (error) { + console.error('GET /admin/tenant-onboarding-config failed', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + +router.put('/admin/tenant-onboarding-config', verifyToken, requireAdmin, async (req, res) => { + try { + const incoming = req.body?.config; + if (!incoming || typeof incoming !== 'object') { + return res.status(400).json({ success: false, message: 'config object is required.' }); + } + const config = sanitizeTenantOnboardingConfig(incoming); + const { TenantOnboardingConfig } = getModels(req, 'TenantOnboardingConfig'); + const updatedBy = req.user.globalUserId || req.user.userId || null; + const doc = await TenantOnboardingConfig.findOneAndUpdate( + { configKey: 'default' }, + { $set: { steps: config.steps, updatedBy } }, + { new: true, upsert: true, setDefaultsOnInsert: true } + ).lean(); + return res.json({ + success: true, + data: { + config: sanitizeTenantOnboardingConfig(doc || null), + updatedAt: doc?.updatedAt || null, + templateLibrary: TENANT_TEMPLATE_LIBRARY, + }, + }); + } catch (error) { + console.error('PUT /admin/tenant-onboarding-config failed', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + router.post("/update-user", verifyToken, async (req, res) => { const { User } = getModels(req, 'User'); const { name, username, classroom, recommendation, onboarded } = req.body diff --git a/backend/schemas/tenantConfig.js b/backend/schemas/tenantConfig.js index f85a0d44..94b9e0f4 100644 --- a/backend/schemas/tenantConfig.js +++ b/backend/schemas/tenantConfig.js @@ -20,6 +20,7 @@ const tenantConfigSchema = new mongoose.Schema( { configKey: { type: String, required: true, unique: true, default: 'default' }, tenants: { type: [tenantEntrySchema], default: [] }, + onboardingConfig: { type: mongoose.Schema.Types.Mixed, default: null }, updatedBy: { type: String, default: null }, }, { timestamps: true } diff --git a/backend/schemas/tenantOnboardingConfig.js b/backend/schemas/tenantOnboardingConfig.js new file mode 100644 index 00000000..e18eaf0d --- /dev/null +++ b/backend/schemas/tenantOnboardingConfig.js @@ -0,0 +1,12 @@ +const mongoose = require('mongoose'); + +const tenantOnboardingConfigSchema = new mongoose.Schema( + { + configKey: { type: String, required: true, unique: true, default: 'default' }, + steps: { type: [mongoose.Schema.Types.Mixed], default: [] }, + updatedBy: { type: String, default: null }, + }, + { timestamps: true } +); + +module.exports = tenantOnboardingConfigSchema; diff --git a/backend/schemas/user.js b/backend/schemas/user.js index d5ec5d04..994114ab 100644 --- a/backend/schemas/user.js +++ b/backend/schemas/user.js @@ -41,6 +41,14 @@ const userSchema = new mongoose.Schema({ type:String, trim:true, }, + onboardingResponses: { + type: mongoose.Schema.Types.Mixed, + default: {}, + }, + onboardingCompletedAt: { + type: Date, + default: null, + }, onboarded:{ type: Boolean, default:false, diff --git a/backend/services/getGlobalModelService.js b/backend/services/getGlobalModelService.js index 27ff3f3f..fd6ca76e 100644 --- a/backend/services/getGlobalModelService.js +++ b/backend/services/getGlobalModelService.js @@ -3,6 +3,7 @@ const platformRoleSchema = require('../schemas/platformRole'); const tenantMembershipSchema = require('../schemas/tenantMembership'); const globalSessionSchema = require('../schemas/globalSession'); const tenantConfigSchema = require('../schemas/tenantConfig'); +const tenantOnboardingConfigSchema = require('../schemas/tenantOnboardingConfig'); /** * Get models from the global/platform DB (cross-tenant data). @@ -10,7 +11,7 @@ const tenantConfigSchema = require('../schemas/tenantConfig'); * Requires req.globalDb to be set (see app.js middleware). * * @param {object} req - request with req.globalDb - * @param {...string} names - model names: 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session', 'TenantConfig' + * @param {...string} names - model names: 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session', 'TenantConfig', 'TenantOnboardingConfig' * @returns {object} map of requested models */ const getGlobalModels = (req, ...names) => { @@ -25,6 +26,7 @@ const getGlobalModels = (req, ...names) => { TenantMembership: db.model('TenantMembership', tenantMembershipSchema, 'tenant_memberships'), Session: db.model('Session', globalSessionSchema, 'sessions'), TenantConfig: db.model('TenantConfig', tenantConfigSchema, 'tenant_config'), + TenantOnboardingConfig: db.model('TenantOnboardingConfig', tenantOnboardingConfigSchema, 'tenant_onboarding_configs'), }; return names.reduce((acc, name) => { diff --git a/backend/services/getModelService.js b/backend/services/getModelService.js index eb99d42d..b99b0e37 100644 --- a/backend/services/getModelService.js +++ b/backend/services/getModelService.js @@ -29,6 +29,7 @@ const androidTesterSignupSchema = require('../schemas/androidTesterSignup'); const resourcesConfigSchema = require('../schemas/resources'); const shuttleConfigSchema = require('../schemas/shuttleConfig'); const noticeConfigSchema = require('../schemas/noticeConfig'); +const tenantOnboardingConfigSchema = require('../schemas/tenantOnboardingConfig'); // Study Sessions const studySessionSchema = require('../schemas/studySession'); const availabilityPollSchema = require('../schemas/availabilityPoll'); @@ -127,6 +128,7 @@ const getModels = (req, ...names) => { ResourcesConfig: req.db.model('ResourcesConfig', resourcesConfigSchema, 'resourcesConfigs'), ShuttleConfig: req.db.model('ShuttleConfig', shuttleConfigSchema, 'shuttleConfigs'), NoticeConfig: req.db.model('NoticeConfig', noticeConfigSchema, 'noticeConfigs'), + TenantOnboardingConfig: req.db.model('TenantOnboardingConfig', tenantOnboardingConfigSchema, 'tenant_onboarding_configs'), }; return names.reduce((acc, name) => { diff --git a/backend/services/onboardingConfigService.js b/backend/services/onboardingConfigService.js new file mode 100644 index 00000000..583505b2 --- /dev/null +++ b/backend/services/onboardingConfigService.js @@ -0,0 +1,253 @@ +const SIGNUP_OPTIONS = ['all', 'email', 'google', 'apple', 'saml']; + +const ONBOARDING_STEP_TYPES = [ + 'short_text', + 'long_text', + 'number', + 'single_select', + 'multi_select', + 'picture_upload', + 'template_follow_orgs', + 'template_add_friends', +]; + +const TENANT_TEMPLATE_LIBRARY = [ + { + id: 'template_follow_orgs', + label: 'Follow Organizations', + description: 'Let users pick campus organizations to follow right away.', + step: { + id: 'follow-orgs', + key: 'follow_orgs', + type: 'template_follow_orgs', + title: 'Follow organizations you care about', + description: 'Select organizations to personalize your feed.', + required: false, + }, + }, + { + id: 'template_add_friends', + label: 'Add Friends', + description: 'Prompt users to send a few initial friend requests.', + step: { + id: 'add-friends', + key: 'add_friends', + type: 'template_add_friends', + title: 'Connect with friends', + description: 'Search for classmates and send friend requests.', + required: false, + }, + }, +]; + +const DEFAULT_PLATFORM_ONBOARDING = { + defaults: [ + { + id: 'name', + key: 'name', + type: 'short_text', + title: 'What should we call you?', + description: 'This name is shown to other users across Meridian.', + placeholder: 'Your display name', + required: true, + }, + { + id: 'picture', + key: 'picture', + type: 'picture_upload', + title: 'Add a profile picture', + description: 'Optional, but helps others recognize you.', + required: false, + }, + ], + bySignupOption: { + all: [], + email: [], + google: [], + apple: [], + saml: [], + }, +}; + +function toSlug(value, fallback) { + const slug = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 48); + return slug || fallback; +} + +function toStepId(rawId, fallback) { + const cleaned = String(rawId || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + return cleaned || fallback; +} + +function normalizeOptions(rawOptions) { + const values = Array.isArray(rawOptions) ? rawOptions : []; + const normalized = values + .map((entry) => { + if (entry == null) return null; + if (typeof entry === 'string') { + const label = entry.trim(); + if (!label) return null; + return { value: toSlug(label, `option_${Date.now()}`), label }; + } + const label = String(entry.label || entry.value || '').trim(); + if (!label) return null; + const value = toSlug(entry.value || label, `option_${Date.now()}`); + return { value, label }; + }) + .filter(Boolean); + + const deduped = []; + const seen = new Set(); + normalized.forEach((option) => { + if (seen.has(option.value)) return; + seen.add(option.value); + deduped.push(option); + }); + return deduped.slice(0, 50); +} + +function sanitizeStep(rawStep = {}, options = {}) { + const { allowTemplates = true } = options; + const fallback = `step_${Date.now().toString(36)}`; + const type = ONBOARDING_STEP_TYPES.includes(rawStep.type) ? rawStep.type : 'short_text'; + const isTemplateType = type === 'template_follow_orgs' || type === 'template_add_friends'; + + if (isTemplateType && !allowTemplates) { + return null; + } + + const key = toSlug(rawStep.key || rawStep.id || rawStep.title, fallback); + const title = String(rawStep.title || rawStep.key || 'Untitled step').trim().slice(0, 120); + const step = { + id: toStepId(rawStep.id, key), + key, + type, + title, + description: String(rawStep.description || '').trim().slice(0, 300), + placeholder: String(rawStep.placeholder || '').trim().slice(0, 180), + required: Boolean(rawStep.required), + }; + + if (type === 'single_select' || type === 'multi_select') { + step.options = normalizeOptions(rawStep.options); + if (step.options.length === 0) { + return null; + } + if (type === 'multi_select') { + const maxSelections = Number(rawStep.maxSelections); + if (Number.isFinite(maxSelections) && maxSelections > 0) { + step.maxSelections = Math.min(Math.floor(maxSelections), 20); + } + } + } + + if (isTemplateType) { + step.templateKey = type === 'template_follow_orgs' ? 'follow_orgs' : 'add_friends'; + } + + return step; +} + +function ensureUniqueStepIds(steps = []) { + const used = new Set(); + return steps.map((step) => { + let candidate = step.id || step.key || `step_${Date.now().toString(36)}`; + let counter = 1; + while (used.has(candidate)) { + candidate = `${step.id || step.key}-${counter++}`; + } + used.add(candidate); + return { ...step, id: candidate }; + }); +} + +function sanitizePlatformOnboardingConfig(rawConfig = null) { + const defaultsRaw = Array.isArray(rawConfig?.defaults) ? rawConfig.defaults : []; + const defaults = ensureUniqueStepIds( + defaultsRaw + .map((step) => sanitizeStep(step, { allowTemplates: false })) + .filter(Boolean) + ); + + const bySignupOption = {}; + SIGNUP_OPTIONS.forEach((option) => { + const rawSteps = Array.isArray(rawConfig?.bySignupOption?.[option]) ? rawConfig.bySignupOption[option] : []; + bySignupOption[option] = ensureUniqueStepIds( + rawSteps + .map((step) => sanitizeStep(step, { allowTemplates: false })) + .filter(Boolean) + ); + }); + + return { defaults, bySignupOption }; +} + +function getPlatformOnboardingConfig(rawConfig = null) { + if (!rawConfig || typeof rawConfig !== 'object') { + return DEFAULT_PLATFORM_ONBOARDING; + } + const sanitized = sanitizePlatformOnboardingConfig(rawConfig); + const hasAnyConfiguredStep = + sanitized.defaults.length > 0 || + SIGNUP_OPTIONS.some((option) => (sanitized.bySignupOption[option] || []).length > 0); + if (!hasAnyConfiguredStep) { + return DEFAULT_PLATFORM_ONBOARDING; + } + return sanitized; +} + +function sanitizeTenantOnboardingConfig(rawConfig = null) { + const stepsRaw = Array.isArray(rawConfig?.steps) ? rawConfig.steps : []; + const steps = ensureUniqueStepIds( + stepsRaw + .map((step) => sanitizeStep(step, { allowTemplates: true })) + .filter(Boolean) + ); + return { steps }; +} + +function detectSignupOption(user) { + if (!user) return 'email'; + if (user.googleId) return 'google'; + if (user.appleId) return 'apple'; + if (user.samlId || user.samlProvider) return 'saml'; + return 'email'; +} + +function resolveOnboardingSteps(platformConfig, tenantConfig, signupOption = 'email') { + const normalizedPlatform = getPlatformOnboardingConfig(platformConfig); + const normalizedTenant = sanitizeTenantOnboardingConfig(tenantConfig); + const signupKey = SIGNUP_OPTIONS.includes(signupOption) ? signupOption : 'email'; + + const merged = [ + ...(normalizedPlatform.defaults || []), + ...(normalizedPlatform.bySignupOption?.all || []), + ...(normalizedPlatform.bySignupOption?.[signupKey] || []), + ...(normalizedTenant.steps || []), + ]; + + return ensureUniqueStepIds(merged); +} + +module.exports = { + SIGNUP_OPTIONS, + ONBOARDING_STEP_TYPES, + TENANT_TEMPLATE_LIBRARY, + DEFAULT_PLATFORM_ONBOARDING, + sanitizeStep, + sanitizePlatformOnboardingConfig, + getPlatformOnboardingConfig, + sanitizeTenantOnboardingConfig, + detectSignupOption, + resolveOnboardingSteps, +}; diff --git a/frontend/src/components/OnboardingBuilder/OnboardingBuilder.jsx b/frontend/src/components/OnboardingBuilder/OnboardingBuilder.jsx new file mode 100644 index 00000000..0580b9c6 --- /dev/null +++ b/frontend/src/components/OnboardingBuilder/OnboardingBuilder.jsx @@ -0,0 +1,291 @@ +import React from 'react'; +import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; +import './OnboardingBuilder.scss'; + +const CUSTOM_STEP_TYPES = [ + { value: 'short_text', label: 'Short text' }, + { value: 'long_text', label: 'Long text' }, + { value: 'number', label: 'Number' }, + { value: 'single_select', label: 'Single select' }, + { value: 'multi_select', label: 'Multi select' }, + { value: 'picture_upload', label: 'Picture upload' }, +]; + +function slugify(value) { + const normalized = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 48); + return normalized || ''; +} + +function makeId() { + return `step-${Math.random().toString(36).slice(2, 10)}`; +} + +function buildNewStep(type = 'short_text') { + const isSelect = type === 'single_select' || type === 'multi_select'; + return { + id: makeId(), + key: '', + type, + title: '', + description: '', + placeholder: '', + required: false, + options: isSelect ? [] : undefined, + }; +} + +function parseOptionsFromText(text) { + return String(text || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((label) => ({ + label, + value: slugify(label), + })) + .filter((entry) => entry.value); +} + +function optionsToTextareaValue(options = []) { + return options.map((option) => option.label || option.value).join('\n'); +} + +function sanitizeStepBeforeSave(step) { + const normalized = { + id: step.id || makeId(), + key: step.key || '', + type: step.type || 'short_text', + title: step.title || '', + description: step.description || '', + placeholder: step.placeholder || '', + required: Boolean(step.required), + }; + if (normalized.type === 'single_select' || normalized.type === 'multi_select') { + normalized.options = Array.isArray(step.options) ? step.options : []; + } + return normalized; +} + +function StepCard({ + step, + index, + onChange, + onDelete, + onMoveUp, + onMoveDown, + allowTemplates, +}) { + const isSelect = step.type === 'single_select' || step.type === 'multi_select'; + const isTemplate = step.type === 'template_follow_orgs' || step.type === 'template_add_friends'; + + return ( +
{template.description}
+ ++ Configure global onboarding steps and per-signup-journey variants. + Tenant-specific steps are added from the Root Dashboard. +
+Loading platform onboarding…
+ ) : ( + <> +Loading…
) : ( diff --git a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.scss b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.scss index 2b507f94..6caa111d 100644 --- a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.scss +++ b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.scss @@ -122,6 +122,24 @@ } } } + + .platform-admins-onboarding { + margin-bottom: 1.5rem; + padding: 1rem; + background: #f8f9fa; + border-radius: 8px; + border: 1px solid #e9ecef; + .builder-header { + margin-bottom: 0.75rem; + h2 { + margin: 0; + } + p { + margin: 0.35rem 0 0; + color: #666; + } + } + } .platform-admins-list { list-style: none; padding: 0; diff --git a/frontend/src/pages/OnBoarding/Onboard.jsx b/frontend/src/pages/OnBoarding/Onboard.jsx index e94703f3..f708ed3b 100644 --- a/frontend/src/pages/OnBoarding/Onboard.jsx +++ b/frontend/src/pages/OnBoarding/Onboard.jsx @@ -1,276 +1,472 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; +import { useNotification } from '../../NotificationContext'; +import { useError } from '../../ErrorContext'; +import useAuth from '../../hooks/useAuth'; +import { + getOnboardingConfig, + searchOnboardingProfiles, + submitOnboarding, +} from './OnboardHelpers'; import './Onboard.scss'; -import PurpleGradient from '../../assets/PurpleGrad.svg'; -import YellowRedGradient from '../../assets/YellowRedGrad.svg'; -import Loader from '../../components/Loader/Loader.jsx'; -import DragList from './DragList/DragList.jsx'; -import useAuth from '../../hooks/useAuth.js'; -import Recommendation from './Recommendation/Recommendation.jsx'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { onboardUser } from './OnboardHelpers.js'; -import { useError } from '../../ErrorContext.js'; -import { useNotification } from '../../NotificationContext.js'; -import { checkUsername } from '../../DBInteractions.js'; -import { useCache } from '../../CacheContext.js'; -import { debounce} from '../../Query.js'; -import check from '../../assets/Icons/Check.svg'; -import waiting from '../../assets/Icons/Waiting.svg'; -import error from '../../assets/circle-warning.svg'; -import unavailable from '../../assets/Icons/Circle-X.svg'; -import CardHeader from '../../components/ProfileCard/CardHeader/CardHeader.jsx'; -function Onboard(){ +function isValuePresent(value) { + if (value == null) return false; + if (typeof value === 'string') return value.trim() !== ''; + if (Array.isArray(value)) return value.length > 0; + return true; +} + +function Onboard() { const [start, setStart] = useState(false); - const [current, setCurrent] = useState(0); - const [show, setShow] = useState(0); - const [currentTransition, setCurrentTransition] = useState(0); - const [containerHeight, setContainerHeight] = useState(175); + const [loadingConfig, setLoadingConfig] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [steps, setSteps] = useState([]); + const [responses, setResponses] = useState({}); + const [pictureFile, setPictureFile] = useState(null); + const [orgSearchQuery, setOrgSearchQuery] = useState(''); + const [friendSearchQuery, setFriendSearchQuery] = useState(''); + const [orgResults, setOrgResults] = useState([]); + const [friendResults, setFriendResults] = useState([]); + const [searchLoading, setSearchLoading] = useState({ orgs: false, users: false }); + const [searchError, setSearchError] = useState(''); + const [configError, setConfigError] = useState(''); + const { isAuthenticated, isAuthenticating, user, validateToken } = useAuth(); - // const { debounce } = useCache(); const { addNotification } = useNotification(); - const [userInfo, setUserInfo] = useState(null); - const [name, setName] = useState(""); - const [username, setUsername] = useState(null); - const [initialUsername, setInitialUsername] = useState(null); - const [sliderValue, setSliderValue] = useState(2); - const [isGoogle, setIsGoogle] = useState(null); - const [onboarded, setOnboarded] = useState(false); - const [usernameValid, setUsernameValid] = useState(0); - - const navigate = useNavigate(); const { newError } = useError(); - - const [buttonActive, setButtonActive] = useState(true); - - const containerRef = useRef(null); - const contentRefs = useRef([]); - + const navigate = useNavigate(); const location = useLocation(); - const from = location.state?.from?.pathname || '/room/none'; + const from = location.state?.from?.pathname || '/events-dashboard'; - - const [items, setItems] = useState(["outlets", "classroom type", "printer", "table type", "windows"]); - const details = { - "outlets": "having outlet access from a majority of seats", - "classroom type": "ex: lecture hall, classroom, auditorium", - "printer": "having a printer in the room", - "table type": "ex: small desks, large tables,", - } - - useEffect(()=>{ - if (containerRef.current) { - setContainerHeight(contentRefs.current[0].clientHeight+10); - } + useEffect(() => { + const timer = setTimeout(() => setStart(true), 200); + return () => clearTimeout(timer); }, []); - const validUsername = async (username) => { - if (username === null || username === "") { - return; - } - if (username === initialUsername) { - setUsernameValid(1); + const onboardingStepKeys = useMemo(() => new Set(steps.map((step) => step.key)), [steps]); + + useEffect(() => { + if (isAuthenticating) return; + if (!isAuthenticated) { + navigate('/login'); return; } - setUsernameValid(0); - try{ - const response = await checkUsername(username); - if(response){ - setUsernameValid(1); - } else { - setUsernameValid(2); - } - } catch (error){ - addNotification({title: 'Error checking username', message: error.message, type: 'error'}); + if (user?.onboarded) { + navigate(from, { replace: true }); } - }; + }, [from, isAuthenticated, isAuthenticating, navigate, user]); - const debounced = useCallback(debounce(validUsername, 500),[]); - - useEffect(() => { - setTimeout(() => { - setStart(true); - }, 500); - },[]); - useEffect(() => { - if(isAuthenticating){ - return; - } - if (!isAuthenticated) { - navigate('/login'); - } else { - if(user){ - if(user.onboarded && (!onboarded)){ - navigate(from, {replace: true}); + if (!isAuthenticated || !user || user.onboarded) return; + let cancelled = false; + async function loadConfig() { + setLoadingConfig(true); + setConfigError(''); + try { + const payload = await getOnboardingConfig(); + if (cancelled) return; + if (!payload?.success) { + throw new Error(payload?.message || 'Failed to load onboarding config'); + } + const nextSteps = Array.isArray(payload?.data?.steps) ? payload.data.steps : []; + setSteps(nextSteps); + const seededResponses = {}; + if (user.name) seededResponses.name = user.name; + if (user.username) seededResponses.username = user.username; + setResponses((prev) => ({ ...seededResponses, ...prev })); + } catch (error) { + if (cancelled) return; + setConfigError(error.message || 'Failed to load onboarding config'); + } finally { + if (!cancelled) { + setLoadingConfig(false); } - setUserInfo(user); - setIsGoogle(user.googleId); - console.log(user); - setUsername(user.googleId ? user.username : null); - setInitialUsername(user.googleId ? user.username : null); - setName(user.name); } } - }, [isAuthenticating, isAuthenticated, user]); + loadConfig(); + return () => { + cancelled = true; + }; + }, [isAuthenticated, user]); - useEffect(() => { - if (username === null || username === "") { - setUsernameValid(3); - return; + const currentStep = steps[currentStepIndex] || null; + + const stepErrors = useMemo(() => { + if (!currentStep) return []; + const errors = []; + const value = responses[currentStep.key]; + if (currentStep.required && !isValuePresent(value) && currentStep.type !== 'picture_upload') { + errors.push('This step is required.'); } - if (username === initialUsername) { - setUsernameValid(1); - return; + if (currentStep.type === 'single_select' && isValuePresent(value) && typeof value !== 'string') { + errors.push('Select one option.'); } - setUsernameValid(0); - debounced(username); - }, [username]); - - useEffect(()=>{ - if(current === 0){return;} - setTimeout(() => { - setCurrentTransition(currentTransition+1); - }, 500); - if (contentRefs.current[current] && current !== 0) { - setTimeout(() => { - setContainerHeight(contentRefs.current[current].offsetHeight); - }, 500); - console.log(contentRefs.current[current].offsetHeight); - console.log(current); + if (currentStep.type === 'multi_select' && isValuePresent(value) && !Array.isArray(value)) { + errors.push('Choose one or more options.'); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [current]); - - async function handleOnboardUser(name, username, items, sliderValue){ - try{ - const response = await onboardUser(name, username, items, sliderValue); - if(response.success){ - setOnboarded(true); - await validateToken(); + if (currentStep.type === 'number' && isValuePresent(value)) { + const num = Number(value); + if (!Number.isFinite(num)) { + errors.push('Enter a valid number.'); } - - } catch (error){ - newError(error, navigate); } - } + return errors; + }, [currentStep, responses]); - useEffect(()=>{ - if(show === 0){return;} - setTimeout(() => { - setCurrent(current+1); - }, 500); - - if(current === 3){ - try{ - handleOnboardUser(name, username, items, sliderValue); - } catch (error){ - newError(error, navigate); - } + const canContinue = useMemo(() => { + if (!currentStep) return false; + if (stepErrors.length > 0) return false; + if (!currentStep.required) return true; + if (currentStep.type === 'picture_upload') { + return true; } + return isValuePresent(responses[currentStep.key]); + }, [currentStep, responses, stepErrors]); - if(current === 4){ - navigate(from, {replace: true}); - } + const completedCount = useMemo(() => { + return steps.filter((step) => { + if (step.type === 'picture_upload') return Boolean(pictureFile) || Boolean(user?.picture); + return isValuePresent(responses[step.key]); + }).length; + }, [steps, responses, pictureFile, user?.picture]); + + const handleResponseChange = useCallback((key, value) => { + setResponses((prev) => ({ + ...prev, + [key]: value, + })); + }, []); - setButtonActive(false); - setTimeout(() => { - setButtonActive(true); - }, 1000); - }, [show]); + const handleOptionToggle = useCallback((key, value) => { + setResponses((prev) => { + const current = Array.isArray(prev[key]) ? prev[key] : []; + const has = current.includes(value); + return { + ...prev, + [key]: has ? current.filter((entry) => entry !== value) : [...current, value], + }; + }); + }, []); + const searchEntities = useCallback(async (type, query) => { + setSearchError(''); + setSearchLoading((prev) => ({ ...prev, [type]: true })); + try { + const result = await searchOnboardingProfiles(type, query); + if (result?.success) { + if (type === 'orgs') { + setOrgResults(Array.isArray(result.data) ? result.data : []); + } else { + setFriendResults(Array.isArray(result.data) ? result.data : []); + } + } else { + setSearchError(result?.message || 'Search failed'); + } + } catch (error) { + setSearchError(error.message || 'Search failed'); + } finally { + setSearchLoading((prev) => ({ ...prev, [type]: false })); + } + }, []); - const [viewport, setViewport] = useState("100vh"); + useEffect(() => { + if (!steps.some((step) => step.type === 'template_follow_orgs')) return; + const timer = setTimeout(() => { + searchEntities('orgs', orgSearchQuery); + }, 250); + return () => clearTimeout(timer); + }, [orgSearchQuery, searchEntities, steps]); useEffect(() => { - setViewport((window.innerHeight) + 'px'); - },[]); + if (!steps.some((step) => step.type === 'template_add_friends')) return; + const timer = setTimeout(() => { + searchEntities('users', friendSearchQuery); + }, 250); + return () => clearTimeout(timer); + }, [friendSearchQuery, searchEntities, steps]); - if(isAuthenticating || !userInfo){ - return( - - ) + const handleSubmit = useCallback(async () => { + if (submitting) return; + setSubmitting(true); + try { + const filteredResponses = Object.keys(responses).reduce((acc, key) => { + if (!onboardingStepKeys.has(key)) return acc; + const value = responses[key]; + if (!isValuePresent(value)) return acc; + acc[key] = value; + return acc; + }, {}); + const result = await submitOnboarding({ responses: filteredResponses, pictureFile }); + if (!result?.success) { + throw new Error(result?.message || 'Failed to complete onboarding'); + } + await validateToken(); + addNotification({ + title: 'Onboarding complete', + message: 'Your profile has been personalized.', + type: 'success', + }); + navigate(from, { replace: true }); + } catch (error) { + newError(error, navigate); + } finally { + setSubmitting(false); + } + }, [addNotification, from, navigate, newError, onboardingStepKeys, pictureFile, responses, submitting, validateToken]); + + const renderStepContent = () => { + if (!currentStep) return null; + const value = responses[currentStep.key]; + if (currentStep.type === 'short_text') { + return ( + handleResponseChange(currentStep.key, event.target.value)} + /> + ); + } + if (currentStep.type === 'long_text') { + return ( +