From 8d3e657c0e29bfd5c2e7b3700393b4a4dd4500c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 22:10:39 +0000 Subject: [PATCH] Implement configurable platform and tenant onboarding Co-authored-by: RavenLLevitt --- backend/routes/adminRoutes.js | 71 ++ backend/routes/userRoutes.js | 374 ++++++++++ backend/schemas/tenantConfig.js | 1 + backend/schemas/tenantOnboardingConfig.js | 12 + backend/schemas/user.js | 8 + backend/services/getGlobalModelService.js | 4 +- backend/services/getModelService.js | 2 + backend/services/onboardingConfigService.js | 253 +++++++ .../OnboardingBuilder/OnboardingBuilder.jsx | 291 ++++++++ .../OnboardingBuilder/OnboardingBuilder.scss | 219 ++++++ .../PlatformAdminsPage/PlatformAdminsPage.jsx | 106 ++- .../PlatformAdminsPage.scss | 18 + frontend/src/pages/OnBoarding/Onboard.jsx | 658 ++++++++++++------ frontend/src/pages/OnBoarding/Onboard.scss | 426 +++++------- .../src/pages/OnBoarding/OnboardHelpers.js | 38 +- frontend/src/pages/Room/Room.jsx | 2 +- frontend/src/pages/Room/Room1.jsx | 3 +- frontend/src/pages/RootDash/RootDash.jsx | 10 +- .../RootManagement/RootManagement.jsx | 11 +- .../RootManagement/RootManagement.scss | 15 + .../TenantOnboarding/TenantOnboarding.jsx | 90 +++ .../TenantOnboarding/TenantOnboarding.scss | 38 + 22 files changed, 2148 insertions(+), 502 deletions(-) create mode 100644 backend/schemas/tenantOnboardingConfig.js create mode 100644 backend/services/onboardingConfigService.js create mode 100644 frontend/src/components/OnboardingBuilder/OnboardingBuilder.jsx create mode 100644 frontend/src/components/OnboardingBuilder/OnboardingBuilder.scss create mode 100644 frontend/src/pages/RootDash/TenantOnboarding/TenantOnboarding.jsx create mode 100644 frontend/src/pages/RootDash/TenantOnboarding/TenantOnboarding.scss 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 ( +
+
+

Step {index + 1}

+
+ + + +
+
+ +
+ + + + +
+ +