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
156 changes: 1 addition & 155 deletions apps/dashboard-api/src/__tests__/analytics.controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,159 +249,5 @@ describe("Analytics Controller", () => {
});
});

describe("getActivationFunnel", () => {
it("should return status of activation funnel steps", async () => {
mockPlatformEventFind.mockReturnValue({
sort: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
lean: jest.fn().mockResolvedValue([
{ event: "signup_completed", timestamp: "2026-06-10T00:00:00Z" },
{ event: "email_verified", timestamp: "2026-06-10T00:05:00Z" },
]),
});

await controller.getActivationFunnel(req, res, next);

expect(res.json).toHaveBeenCalledTimes(1);
const responseData = res.json.mock.calls[0][0];
expect(responseData.success).toBe(true);
const steps = responseData.data.steps;
expect(steps[0]).toEqual({
step: "signup_completed",
order: 1,
completed: true,
completedAt: "2026-06-10T00:00:00Z",
});
expect(steps[1]).toEqual({
step: "email_verified",
order: 2,
completed: true,
completedAt: "2026-06-10T00:05:00Z",
});
expect(steps[2]).toEqual({
step: "project_created",
order: 3,
completed: false,
completedAt: null,
});
});
});

describe("getRetention", () => {
it("should return d1/d7/d30 retention flags", async () => {
mockPlatformEventFindOne.mockReturnValue({
sort: jest.fn().mockReturnThis(),
lean: jest.fn().mockResolvedValue({
timestamp: "2026-06-10T12:00:00Z",
}),
});

mockDeveloperActivityFindOne.mockImplementation(({ date }) => {
return {
lean: jest.fn().mockResolvedValue({ date }),
};
});

await controller.getRetention(req, res, next);

expect(res.json).toHaveBeenCalledTimes(1);
const responseData = res.json.mock.calls[0][0];
expect(responseData.success).toBe(true);
expect(responseData.data.d1).toBe(true);
expect(responseData.data.d7).toBe(true);
expect(responseData.data.d30).toBe(true);
});

it("should return false flags if no signup event exists", async () => {
mockPlatformEventFindOne.mockReturnValue({
sort: jest.fn().mockReturnThis(),
lean: jest.fn().mockResolvedValue(null),
});

await controller.getRetention(req, res, next);

expect(res.json).toHaveBeenCalledTimes(1);
const responseData = res.json.mock.calls[0][0];
expect(responseData.data).toEqual({
d1: false,
d7: false,
d30: false,
signupDate: null,
});
});
});

describe("getEngagement", () => {
it("should return aggregated engagement metrics", async () => {
mockDeveloperActivityAggregate.mockResolvedValueOnce([
{
totalApiCalls: 500,
totalMailSent: 50,
totalStorageUploads: 10,
totalWebhooksFired: 5,
activeDays: 3,
allProjectIds: [["proj1"], ["proj2", "proj1"]],
},
]);

await controller.getEngagement(req, res, next);

expect(res.json).toHaveBeenCalledTimes(1);
const responseData = res.json.mock.calls[0][0];
expect(responseData.success).toBe(true);
expect(responseData.data).toEqual({
window: "30d",
totalApiCalls: 500,
totalMailSent: 50,
totalStorageUploads: 10,
totalWebhooksFired: 5,
activeDays: 3,
uniqueActiveProjects: 2,
});
});
});

describe("getNorthStar", () => {
it("should return north star metrics", async () => {
mockProjectFind.mockReturnValue({
select: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue([
{ _id: "proj1", name: "Proj 1" },
{ _id: "proj2", name: "Proj 2" },
]),
}),
});

mockLogDistinct.mockResolvedValueOnce(["proj1"]);

await controller.getNorthStar(req, res, next);

expect(res.json).toHaveBeenCalledTimes(1);
const responseData = res.json.mock.calls[0][0];
expect(responseData.success).toBe(true);
expect(responseData.data).toEqual({
activeProjects: 1,
totalProjects: 2,
percentage: 50,
});
});

it("should return 0 metrics if developer has no projects", async () => {
mockProjectFind.mockReturnValue({
select: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue([]),
}),
});

await controller.getNorthStar(req, res, next);

expect(res.json).toHaveBeenCalledTimes(1);
const responseData = res.json.mock.calls[0][0];
expect(responseData.data).toEqual({
activeProjects: 0,
totalProjects: 0,
percentage: 0,
});
});
});
});

203 changes: 0 additions & 203 deletions apps/dashboard-api/src/controllers/analytics.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,206 +145,3 @@ module.exports.getRecentActivity = async (req, res, next) => {
}
};

// ---------------------------------------------------------------------------
// ACTIVATION FUNNEL
// Returns step-by-step conversion rates for the current developer.
// ---------------------------------------------------------------------------
module.exports.getActivationFunnel = async (req, res, next) => {
try {
const developerId = req.user._id;

const FUNNEL_STEPS = [
"signup_completed",
"email_verified",
"project_created",
"collection_created",
"first_api_success",
];
const EVENT_ALIASES = {
first_api_success: 'first_api_call',
};

// Fetch one event per step (we only need existence, not count)
const events = await PlatformEvent.find({
developerId,
event: { $in: [...FUNNEL_STEPS, ...Object.keys(EVENT_ALIASES)] },
})
.sort({ timestamp: 1 })
.select("event timestamp")
.lean();

const completed = {};
for (const e of events) {
const step = EVENT_ALIASES[e.event] || e.event;
if (!completed[step]) completed[step] = e.timestamp;
}

const steps = FUNNEL_STEPS.map((step, i) => ({
step,
order: i + 1,
completed: !!completed[step],
completedAt: completed[step] || null,
}));

return new ApiResponse({ steps }).send(res);
} catch (err) {
next(err);
}
};

// ---------------------------------------------------------------------------
// RETENTION (D1 / D7 / D30)
// Checks whether the developer was active on Day+1, Day+7, Day+30 after signup.
// ---------------------------------------------------------------------------
module.exports.getRetention = async (req, res, next) => {
try {
const developerId = req.user._id;

// Find signup event to anchor the cohort start date
const signupEvent = await PlatformEvent.findOne({
developerId,
event: "signup_completed",
})
.sort({ timestamp: 1 })
.lean();

if (!signupEvent) {
return new ApiResponse({
d1: false,
d7: false,
d30: false,
signupDate: null,
}).send(res);
}

const signupDate = new Date(signupEvent.timestamp);
signupDate.setUTCHours(0, 0, 0, 0);

const checkDay = async (daysAfter) => {
const targetDate = new Date(signupDate);
targetDate.setUTCDate(targetDate.getUTCDate() + daysAfter);
const nextDate = new Date(targetDate);
nextDate.setUTCDate(nextDate.getUTCDate() + 1);

const activity = await DeveloperActivity.findOne({
developerId,
date: { $gte: targetDate, $lt: nextDate },
}).lean();
return !!activity;
};

const [d1, d7, d30] = await Promise.all([
checkDay(1),
checkDay(7),
checkDay(30),
]);

return new ApiResponse({ d1, d7, d30, signupDate }).send(res);
} catch (err) {
next(err);
}
};

// ---------------------------------------------------------------------------
// FEATURE ENGAGEMENT (trailing 30 days)
// Returns per-feature usage totals across all projects for the developer.
// ---------------------------------------------------------------------------
module.exports.getEngagement = async (req, res, next) => {
try {
const developerId = req.user._id;

const thirtyDaysAgo = new Date();
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);

const agg = await DeveloperActivity.aggregate([
{
$match: {
developerId: new mongoose.Types.ObjectId(developerId),
date: { $gte: thirtyDaysAgo },
},
},
{
$group: {
_id: null,
totalApiCalls: { $sum: "$apiCallCount" },
totalMailSent: { $sum: "$mailSentCount" },
totalStorageUploads: { $sum: "$storageUploadsCount" },
totalWebhooksFired: { $sum: "$webhookTriggeredCount" },
activeDays: { $sum: 1 },
allProjectIds: { $push: "$activeProjectIds" },
},
},
]);

const result = agg[0] || {
totalApiCalls: 0,
totalMailSent: 0,
totalStorageUploads: 0,
totalWebhooksFired: 0,
activeDays: 0,
};

// Unique active projects in the 30-day window
const flatProjectIds = (result.allProjectIds || []).flat();
const uniqueActiveProjects = new Set(flatProjectIds.map(String)).size;

return new ApiResponse({
window: "30d",
totalApiCalls: result.totalApiCalls,
totalMailSent: result.totalMailSent,
totalStorageUploads: result.totalStorageUploads,
totalWebhooksFired: result.totalWebhooksFired,
activeDays: result.activeDays,
uniqueActiveProjects,
}).send(res);
} catch (err) {
next(err);
}
};

// ---------------------------------------------------------------------------
// NORTH STAR METRIC
// "Projects making successful API calls in the last 7 days"
// ---------------------------------------------------------------------------
module.exports.getNorthStar = async (req, res, next) => {
try {
const developerId = req.user._id;

const sevenDaysAgo = new Date();
sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7);

// Projects owned by this developer (North Star should be owner-only)
const allProjects = await Project.find({ owner: developerId })
.select("_id name")
.lean();
const projectIds = allProjects.map((p) => p._id);
const totalProjects = projectIds.length;

if (totalProjects === 0) {
return new ApiResponse({
activeProjects: 0,
totalProjects: 0,
percentage: 0,
}).send(res);
}

// Projects with at least one 2xx log in the last 7 days
const activeProjectIds = await Log.distinct("projectId", {
projectId: { $in: projectIds },
status: { $gte: 200, $lt: 300 },
timestamp: { $gte: sevenDaysAgo },
});

const activeProjects = activeProjectIds.length;
const percentage =
totalProjects > 0
? Math.round((activeProjects / totalProjects) * 100)
: 0;

return new ApiResponse({ activeProjects, totalProjects, percentage }).send(
res,
);
} catch (err) {
next(err);
}
};
7 changes: 0 additions & 7 deletions apps/dashboard-api/src/routes/analytics.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
const express = require("express");
const router = express.Router();
const analyticsController = require("../controllers/analytics.controller");
const authMiddleware = require("../middlewares/authMiddleware");
const authFlexible = require("../middlewares/authFlexible");

router.get("/stats", authFlexible, analyticsController.getGlobalStats);
router.get("/activity", authFlexible, analyticsController.getRecentActivity);

// --- Metrics Stack ---
router.get("/funnel", authMiddleware, analyticsController.getActivationFunnel);
router.get("/retention", authMiddleware, analyticsController.getRetention);
router.get("/engagement", authMiddleware, analyticsController.getEngagement);
router.get("/north-star", authMiddleware, analyticsController.getNorthStar);

module.exports = router;
Loading
Loading