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
55 changes: 55 additions & 0 deletions backend/constants/orgBetaFeatures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Per-organization beta feature registry (server source of truth).
* Keep keys in sync with Meridian/frontend/src/constants/orgBetaFeatures.js
*/
const ORG_BETA_FEATURE_ORG_TASKS = 'org_tasks';

const ORG_BETA_FEATURE_CATALOG = {
[ORG_BETA_FEATURE_ORG_TASKS]: {
label: 'Organization task hub',
description: 'Cross-event operational tasks and org-level task board in Club Dashboard.',
clubDashMenuKey: 'tasks'
}
};

const ORG_BETA_FEATURE_KEYS = Object.freeze(Object.keys(ORG_BETA_FEATURE_CATALOG));

function orgHasBetaFeature(org, featureKey) {
const keys = org && org.betaFeatureKeys;
if (!Array.isArray(keys)) return false;
return keys.includes(featureKey);
}

/**
* @param {unknown} keys
* @returns {{ ok: true, keys: string[] } | { ok: false, error: string }}
*/
function validateBetaFeatureKeysArray(keys) {
if (!Array.isArray(keys)) {
return { ok: false, error: 'enabledKeys must be an array' };
}
const invalid = keys.filter((k) => typeof k !== 'string' || !ORG_BETA_FEATURE_KEYS.includes(k));
if (invalid.length) {
return { ok: false, error: `Unknown beta feature keys: ${invalid.join(', ')}` };
}
const unique = [...new Set(keys)];
return { ok: true, keys: unique };
}

function getBetaFeatureCatalogForApi() {
return ORG_BETA_FEATURE_KEYS.map((key) => ({
key,
label: ORG_BETA_FEATURE_CATALOG[key].label,
description: ORG_BETA_FEATURE_CATALOG[key].description,
clubDashMenuKey: ORG_BETA_FEATURE_CATALOG[key].clubDashMenuKey || null
}));
}

module.exports = {
ORG_BETA_FEATURE_ORG_TASKS,
ORG_BETA_FEATURE_KEYS,
ORG_BETA_FEATURE_CATALOG,
orgHasBetaFeature,
validateBetaFeatureKeysArray,
getBetaFeatureCatalogForApi
};
62 changes: 62 additions & 0 deletions backend/routes/orgManagementRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const adminTenantSummaryService = require('../services/adminTenantSummaryService
const adminTenantEventsService = require('../services/adminTenantEventsService');
const adminTenantEventOperatorService = require('../services/adminTenantEventOperatorService');
const rootOperatorUsersService = require('../services/rootOperatorUsersService');
const {
validateBetaFeatureKeysArray,
getBetaFeatureCatalogForApi
} = require('../constants/orgBetaFeatures');

const router = express.Router();

Expand Down Expand Up @@ -363,6 +367,22 @@ router.get('/config', verifyToken, async (req, res) => {
}
});

// Catalog of org-level beta features (Atlas / admin tooling)
router.get('/beta-feature-catalog', verifyToken, requireAdmin, (req, res) => {
try {
res.status(200).json({
success: true,
data: { features: getBetaFeatureCatalogForApi() }
});
} catch (error) {
console.error('GET /org-management/beta-feature-catalog failed:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to load beta feature catalog'
});
}
});

router.get('/onboarding-config', verifyToken, async (req, res) => {
const { OrgManagementConfig } = getModels(req, 'OrgManagementConfig');
try {
Expand Down Expand Up @@ -2341,6 +2361,48 @@ router.put('/organizations/:orgId', verifyToken, requireAdmin, async (req, res)
}
});

// Replace enabled per-org beta feature keys (validated against platform registry)
router.patch('/organizations/:orgId/beta-features', verifyToken, requireAdmin, async (req, res) => {
const { Org } = getModels(req, 'Org');
const { orgId } = req.params;
const enabledKeys = req.body && req.body.enabledKeys;

const parsed = validateBetaFeatureKeysArray(enabledKeys);
if (!parsed.ok) {
return res.status(400).json({
success: false,
message: parsed.error
});
}

try {
const org = await Org.findById(orgId);
if (!org) {
return res.status(404).json({
success: false,
message: 'Organization not found'
});
}

org.betaFeatureKeys = parsed.keys;
await org.save();

console.log(`PATCH: /org-management/organizations/${orgId}/beta-features`);
res.status(200).json({
success: true,
message: 'Beta features updated',
data: org
});
} catch (error) {
console.error('Error updating org beta features:', error);
res.status(500).json({
success: false,
message: 'Error updating beta features',
error: error.message
});
}
});

// PATCH lifecycle (platform admin)
router.patch('/organizations/:orgId/lifecycle', verifyToken, requireAdmin, async (req, res) => {
const { Org, OrgManagementConfig } = getModels(req, 'Org', 'OrgManagementConfig');
Expand Down
30 changes: 30 additions & 0 deletions backend/routes/taskManagementRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const {
getAllowedStatusKeys,
DEFAULT_TASK_BOARD_STATUSES
} = require('../services/taskBoardStatusUtils');
const {
ORG_BETA_FEATURE_ORG_TASKS,
orgHasBetaFeature
} = require('../constants/orgBetaFeatures');

function toBoolean(value, defaultValue = false) {
if (value === undefined || value === null || value === '') return defaultValue;
Expand Down Expand Up @@ -182,6 +186,26 @@ async function loadOrgTaskBoardConfig(models, orgId) {
return getResolvedTaskBoardStatuses(org);
}

/** Returns false if response was already sent (404/403). */
async function assertOrgTasksHubBeta(req, res, orgId) {
const models = getModels(req, 'Org');
const org = await models.Org.findById(asObjectId(orgId)).select('betaFeatureKeys').lean();
if (!org) {
res.status(404).json({ success: false, message: 'Organization not found' });
return false;
}
if (!orgHasBetaFeature(org, ORG_BETA_FEATURE_ORG_TASKS)) {
res.status(403).json({
success: false,
code: 'BETA_FEATURE_DISABLED',
featureKey: ORG_BETA_FEATURE_ORG_TASKS,
message: 'Organization task hub is not enabled for this organization'
});
return false;
}
return true;
}

async function ensureOrgEventAccess(models, orgId, eventId) {
if (!eventId) return null;
const event = await models.Event.findOne({
Expand Down Expand Up @@ -448,6 +472,7 @@ router.get('/:orgId/tasks/hub', verifyToken, requireEventManagement('orgId'), as
const models = getModels(req, 'Task', 'Event', 'Org');

try {
if (!(await assertOrgTasksHubBeta(req, res, orgId))) return;
const tasks = await listTasks(models, orgId, {
eventId: eventId === 'all' ? undefined : eventId,
status,
Expand Down Expand Up @@ -485,6 +510,7 @@ router.get('/:orgId/tasks/hub/:taskId', verifyToken, requireEventManagement('org
const models = getModels(req, 'Task', 'Org');

try {
if (!(await assertOrgTasksHubBeta(req, res, orgId))) return;
const task = await findOneTaskDto(models, orgId, taskId, null);
if (!task) {
return res.status(404).json({ success: false, message: 'Task not found' });
Expand All @@ -503,6 +529,7 @@ router.post('/:orgId/tasks/hub', verifyToken, requireEventManagement('orgId'), a
const models = getModels(req, 'Task', 'Org');

try {
if (!(await assertOrgTasksHubBeta(req, res, orgId))) return;
if (!payload.title || !String(payload.title).trim()) {
return res.status(400).json({ success: false, message: 'Task title is required' });
}
Expand Down Expand Up @@ -549,6 +576,7 @@ router.put('/:orgId/tasks/hub/column-order', verifyToken, requireEventManagement
const models = getModels(req, 'Task');

try {
if (!(await assertOrgTasksHubBeta(req, res, orgId))) return;
if (!Array.isArray(taskIds)) {
return res.status(400).json({ success: false, message: 'taskIds must be an array' });
}
Expand All @@ -567,6 +595,7 @@ router.put('/:orgId/tasks/hub/:taskId', verifyToken, requireEventManagement('org
const models = getModels(req, 'Task', 'Event', 'Org');

try {
if (!(await assertOrgTasksHubBeta(req, res, orgId))) return;
const task = await models.Task.findOne({
_id: asObjectId(taskId),
orgId: asObjectId(orgId)
Expand Down Expand Up @@ -600,6 +629,7 @@ router.delete('/:orgId/tasks/hub/:taskId', verifyToken, requireEventManagement('
const { orgId, taskId } = req.params;
const models = getModels(req, 'Task');
try {
if (!(await assertOrgTasksHubBeta(req, res, orgId))) return;
const deleted = await models.Task.findOneAndDelete({
_id: asObjectId(taskId),
orgId: asObjectId(orgId)
Expand Down
5 changes: 5 additions & 0 deletions backend/schemas/org.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,11 @@ const OrgSchema= new Schema({
ref: 'User',
default: null
},
/** Enabled per-org beta feature keys (validated on write against platform registry). */
betaFeatureKeys: {
type: [String],
default: []
},
/** Custom task hub / event task Kanban columns (max 10). Empty/absent = platform defaults. */
taskBoardStatuses: {
type: [{
Expand Down
22 changes: 21 additions & 1 deletion backend/services/analyticsDashboardService.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ async function getOverviewMetrics(AnalyticsEvent, timeRange = '30d', platform) {
$group: {
_id: null,
avgDuration: { $avg: '$duration' },
durationPercentiles: {
$percentile: {
input: '$duration',
p: [0.5],
method: 'approximate'
}
},
sessionCount: { $sum: 1 }
}
},
Expand All @@ -197,12 +204,24 @@ async function getOverviewMetrics(AnalyticsEvent, timeRange = '30d', platform) {
avgDurationMs: '$avgDuration',
avgDurationSeconds: {
$divide: ['$avgDuration', 1000]
},
medianDurationSeconds: {
$divide: [
{
$ifNull: [
{ $arrayElemAt: [{ $ifNull: ['$durationPercentiles', []] }, 0] },
0
]
},
1000
]
}
}
}
]);

const avgSessionDurationSeconds = sessionDurationResult[0]?.avgDurationSeconds || 0;
const medianSessionDurationSeconds = sessionDurationResult[0]?.medianDurationSeconds || 0;

// Web vs Mobile breakdown
// Unique users by platform type
Expand Down Expand Up @@ -304,7 +323,8 @@ async function getOverviewMetrics(AnalyticsEvent, timeRange = '30d', platform) {
sessions,
pageViews,
bounceRate: Math.round(bounceRate * 100) / 100, // Round to 2 decimals
avgSessionDuration: Math.round(avgSessionDurationSeconds)
avgSessionDuration: Math.round(avgSessionDurationSeconds),
medianSessionDuration: Math.round(medianSessionDurationSeconds)
};
// Only include web/mobile breakdown when not filtering by platform
if (!platform) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ jest.mock('../../events/backendRoot', () => {
const backendPath = path.resolve(__dirname, '../..');
const schema = require(path.join(backendPath, 'events/schemas/analyticsEvent'));
const { getOrCreateModel } = require(path.join(backendPath, 'tests/helpers/mongoMemory'));
const userSchema = new (require('mongoose').Schema)({ createdAt: Date }, { collection: 'users' });
const getModels = (req, ...names) => {
const models = {
AnalyticsEvent: getOrCreateModel(req.db, 'AnalyticsEvent', schema, 'analytics_events'),
User: getOrCreateModel(req.db, 'User', userSchema, 'users'),
};
return names.reduce((acc, name) => {
if (models[name]) acc[name] = models[name];
Expand Down Expand Up @@ -90,4 +92,27 @@ describe('analytics dashboard route outcome tests (multi-tenant)', () => {
expect(response.body.data.screens).toEqual(expect.any(Array));
expect(response.body.data.events).toEqual(expect.any(Array));
});

test('GET /dashboard/general-snapshot returns kpi, timeseries, mobile', async () => {
const response = await request(app).get('/dashboard/general-snapshot?timeRange=7d&platform=web');

expect(response.statusCode).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.kpiSummary).toBeDefined();
expect(response.body.data.kpiSummary.current.uniqueDevices).toBeDefined();
expect(response.body.data.kpiSummary.previous.uniqueDevices).toBeDefined();
expect(response.body.data.kpiSummary.deltas.uniqueDevices).toBeDefined();
expect(response.body.data.timeseries).toBeDefined();
expect(response.body.data.mobileSummary).toBeDefined();
});

test('GET /dashboard/overview with custom startDate/endDate returns custom timeRange', async () => {
const response = await request(app).get(
'/dashboard/overview?timeRange=30d&startDate=2025-01-01T00:00:00.000Z&endDate=2025-01-31T23:59:59.999Z'
);

expect(response.statusCode).toBe(200);
expect(response.body.data.timeRange).toBe('custom');
expect(response.body.data.startDate).toBeDefined();
});
});
Binary file added frontend/src/assets/AdminBackground.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
.header-content{
display:flex;
flex-direction: row;
align-items: flex-end;
}
h2{
font-size: 0.85rem;
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/constants/orgBetaFeatures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Per-organization beta feature keys for Club Dash / Atlas UI.
* Keep keys in sync with Meridian/backend/constants/orgBetaFeatures.js
*/
export const ORG_BETA_FEATURE_ORG_TASKS = 'org_tasks';

export const ORG_BETA_FEATURE_CATALOG = {
[ORG_BETA_FEATURE_ORG_TASKS]: {
label: 'Organization task hub',
description: 'Tasks tab and org-level task hub APIs.',
clubDashMenuKey: 'tasks'
}
};

export const ORG_BETA_FEATURE_KEYS = Object.freeze(Object.keys(ORG_BETA_FEATURE_CATALOG));

export function orgHasBetaFeature(overview, featureKey) {
const keys = overview && overview.betaFeatureKeys;
if (!Array.isArray(keys)) return false;
return keys.includes(featureKey);
}
8 changes: 8 additions & 0 deletions frontend/src/pages/Admin/Admin.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import AnalyticsDashboard from '../FeatureAdmin/AnalyticsDashboard/AnalyticsDash
import MobileAnalyticsDashboard from '../FeatureAdmin/MobileAnalyticsDashboard/MobileAnalyticsDashboard';
import UserJourneyAnalytics from '../FeatureAdmin/UserJourneyAnalytics/UserJourneyAnalytics';
import IndividualUserJourney from '../FeatureAdmin/IndividualUserJourney/IndividualUserJourney';
import OrgBetaFeatures from '../FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures';
import AdminTenantDropdown from './AdminTenantDropdown/AdminTenantDropdown';

import AdminLogo from '../../assets/Brand Image/ADMIN.svg';

Expand Down Expand Up @@ -43,6 +45,11 @@ function Admin(){
icon: 'mdi:view-dashboard-variant',
element: <OperatorHubMode />,
},
{
label: 'Beta features',
icon: 'mdi:flask-outline',
element: <OrgBetaFeatures />,
},
{
label: 'Analytics',
icon: 'bx:stats',
Expand Down Expand Up @@ -101,6 +108,7 @@ function Admin(){
menuItems={menuItems}
additionalClass='admin'
logo={AdminLogo}
middleItem={<AdminTenantDropdown />}
onBack={()=>navigate('/events-dashboard')}
enableSubSidebar={true}
>
Expand Down
4 changes: 0 additions & 4 deletions frontend/src/pages/Admin/Admin.scss
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
.admin{
&.general-dash {
.dash-left {
width: 240px;
min-width: 240px;
}

.dash-left .nav ul li p {
white-space: nowrap;
Expand Down
Loading