Skip to content
Open
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
121 changes: 121 additions & 0 deletions apps/lfx-one/src/server/controllers/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1668,4 +1668,125 @@ export class AnalyticsController {
next(error);
}
}

/**
* GET /api/analytics/web-activities-summary
* Get web activities summary grouped by domain category
*/
public async getWebActivitiesSummary(req: Request, res: Response, next: NextFunction): Promise<void> {
const startTime = logger.startOperation(req, 'get_web_activities_summary');

try {
const foundationSlug = req.query['foundationSlug'] as string | undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 Blocker: Inconsistent parameter naming — foundationSlug vs foundationName across the 4 endpoints

Problem: getWebActivitiesSummary accepts foundationSlug (filtering by PROJECT_SLUG), while the other 3 endpoints accept foundationName (filtering by FOUNDATION_NAME / PROJECT_NAME). The frontend (PR #347) calls them as:

this.analyticsService.getWebActivitiesSummary(foundation.slug)
this.analyticsService.getEmailCtr(foundation.name)

Why it's a problem:

  1. API inconsistency — consumers must remember which endpoint takes a slug vs a name. Every other analytics endpoint in this controller uses a single identifier pattern.
  2. Fragile couplingfoundation.slug and foundation.name are different values. If a foundation's display name changes but slug doesn't (or vice versa), one endpoint breaks while the other works.
  3. Email CTR queries by PROJECT_NAME (line 1739, 1751, 1762) but the parameter is called foundationName — this is semantically confusing since projects ≠ foundations.

Fix: Align all 4 endpoints on a single identifier. If the Snowflake tables use different filter columns, document why — but the API surface should ideally use one parameter (preferably foundationSlug since slugs are stable identifiers).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By design — documented in a comment at line 1672. Web Activities uses foundationSlug because WEB_ACTIVITIES_* tables key on PROJECT_SLUG. Email CTR, Social Reach, and Social Media use foundationName because those Platinum tables key on FOUNDATION_NAME/PROJECT_NAME. The frontend passes the correct identifier for each endpoint (foundation.slug vs foundation.name). Unifying would require either a backend JOIN (fragile) or Snowflake schema changes.


if (!foundationSlug) {
throw ServiceValidationError.forField('foundationSlug', 'foundationSlug query parameter is required', {
operation: 'get_web_activities_summary',
});
}

const response = await this.projectService.getWebActivitiesSummary(foundationSlug);

logger.success(req, 'get_web_activities_summary', startTime, {
foundation_slug: foundationSlug,
total_sessions: response.totalSessions,
domain_groups_count: response.domainGroups.length,
daily_data_points: response.dailyData.length,
});

res.json(response);
} catch (error) {
logger.error(req, 'get_web_activities_summary', startTime, error);
next(error);
}
}

/**
* GET /api/analytics/email-ctr
* Get email click-through rate data
*/
public async getEmailCtr(req: Request, res: Response, next: NextFunction): Promise<void> {
const startTime = logger.startOperation(req, 'get_email_ctr');

try {
const foundationName = req.query['foundationName'] as string | undefined;

if (!foundationName) {
throw ServiceValidationError.forField('foundationName', 'foundationName query parameter is required', {
operation: 'get_email_ctr',
});
}

const response = await this.projectService.getEmailCtr(foundationName);

logger.success(req, 'get_email_ctr', startTime, {
foundation_name: foundationName,
current_ctr: response.currentCtr,
monthly_data_points: response.monthlyData.length,
});

res.json(response);
} catch (error) {
logger.error(req, 'get_email_ctr', startTime, error);
next(error);
}
}

public async getSocialReach(req: Request, res: Response, next: NextFunction): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 Blocker: getSocialReach controller method missing JSDoc comment

Problem: The other 3 new controller methods (getWebActivitiesSummary, getEmailCtr, getSocialMedia) all have JSDoc blocks:

/**
 * GET /api/analytics/web-activities-summary
 * Get web activities summary grouped by domain category
 */

But getSocialReach has no JSDoc at all.

Why it's a problem: Every existing method in analytics.controller.ts follows the /** GET /api/analytics/... */ JSDoc pattern. This breaks consistency and makes the endpoint harder to discover via IDE hover/search.

Fix: Add the missing JSDoc:

/**
 * GET /api/analytics/social-reach
 * Get paid social reach and ROAS data
 */
public async getSocialReach(...)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 7d3eb2c. JSDoc added.

const startTime = logger.startOperation(req, 'get_social_reach');

try {
const foundationName = req.query['foundationName'] as string | undefined;

if (!foundationName) {
throw ServiceValidationError.forField('foundationName', 'foundationName query parameter is required', {
operation: 'get_social_reach',
});
}

const response = await this.projectService.getSocialReach(foundationName);

logger.success(req, 'get_social_reach', startTime, {
foundation_name: foundationName,
total_reach: response.totalReach,
monthly_data_points: response.monthlyData.length,
});

res.json(response);
} catch (error) {
logger.error(req, 'get_social_reach', startTime, error);
next(error);
}
}

/**
* GET /api/analytics/social-media
* Get social media metrics from Snowflake Platinum tables
*/
public async getSocialMedia(req: Request, res: Response, next: NextFunction): Promise<void> {
const startTime = logger.startOperation(req, 'get_social_media');

try {
const foundationName = req.query['foundationName'] as string | undefined;

if (!foundationName) {
throw ServiceValidationError.forField('foundationName', 'foundationName query parameter is required', {
operation: 'get_social_media',
});
}

const response = await this.projectService.getSocialMedia(foundationName);

logger.success(req, 'get_social_media', startTime, {
foundation_name: foundationName,
total_followers: response.totalFollowers,
platforms_count: response.platforms.length,
});

res.json(response);
} catch (error) {
logger.error(req, 'get_social_media', startTime, error);
next(error);
}
}
}
12 changes: 12 additions & 0 deletions apps/lfx-one/src/server/routes/analytics.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,16 @@ router.get('/org-training-enrollments-distribution', (req, res, next) => analyti
router.get('/org-certified-employees-monthly', (req, res, next) => analyticsController.getOrgCertifiedEmployeesMonthly(req, res, next));
router.get('/org-certified-employees-distribution', (req, res, next) => analyticsController.getOrgCertifiedEmployeesDistribution(req, res, next));

// Web activities summary endpoint (marketing dashboard)
router.get('/web-activities-summary', (req, res, next) => analyticsController.getWebActivitiesSummary(req, res, next));

// Email CTR endpoint (marketing dashboard)
router.get('/email-ctr', (req, res, next) => analyticsController.getEmailCtr(req, res, next));

// Social reach endpoint (marketing dashboard)
router.get('/social-reach', (req, res, next) => analyticsController.getSocialReach(req, res, next));

// Social media endpoint (marketing dashboard)
router.get('/social-media', (req, res, next) => analyticsController.getSocialMedia(req, res, next));

export default router;
Loading
Loading