From 62a8a4bb830c3a0fa25a616545966f8fbdda5d9d Mon Sep 17 00:00:00 2001 From: KushagraJaiswar02 Date: Sun, 14 Jun 2026 18:42:53 +0530 Subject: [PATCH 01/23] onboarding fixes --- .../src/__tests__/auth.controller.test.js | 43 +++++++++++++ .../src/__tests__/routes.user.test.js | 59 +++++++++++++++++ apps/dashboard-api/src/app.js | 2 + .../src/controllers/auth.controller.js | 4 +- .../src/controllers/project.controller.js | 13 ++++ apps/dashboard-api/src/routes/user.js | 8 +++ .../src/__tests__/api_usage.test.js | 53 ++++++++++++++- apps/public-api/src/middlewares/api_usage.js | 3 +- .../components/Dashboard/UsageProgressBar.jsx | 12 ++-- .../src/components/Layout/Sidebar.jsx | 31 +++++---- .../src/components/ThemeToggle.jsx | 3 +- apps/web-dashboard/src/pages/Analytics.jsx | 7 +- apps/web-dashboard/src/utils/quota.js | 13 ++++ package-lock.json | 32 +++++++++- packages/common/src/index.js | 5 +- packages/common/src/models/Developer.js | 34 ++++++++++ packages/common/src/utils/emailService.js | 5 ++ packages/common/src/utils/onboarding.js | 64 +++++++++++++++++++ 18 files changed, 362 insertions(+), 29 deletions(-) create mode 100644 apps/dashboard-api/src/__tests__/routes.user.test.js create mode 100644 apps/dashboard-api/src/routes/user.js create mode 100644 apps/web-dashboard/src/utils/quota.js create mode 100644 packages/common/src/utils/onboarding.js diff --git a/apps/dashboard-api/src/__tests__/auth.controller.test.js b/apps/dashboard-api/src/__tests__/auth.controller.test.js index 372e1cee5..c07ed14dd 100644 --- a/apps/dashboard-api/src/__tests__/auth.controller.test.js +++ b/apps/dashboard-api/src/__tests__/auth.controller.test.js @@ -84,6 +84,18 @@ jest.mock('@urbackend/common', () => { } }, sendOtp: jest.fn().mockResolvedValue(undefined), + normalizeOnboarding: jest.fn((onboarding = {}) => { + const steps = onboarding.steps || {}; + return { + completed: Boolean(onboarding.completed), + steps: { + projectCreated: Boolean(steps.projectCreated), + collectionCreated: Boolean(steps.collectionCreated), + firstApiCall: Boolean(steps.firstApiCall), + }, + activationAt: onboarding.activationAt || null, + }; + }), // Use real zod shapes so validation logic is exercised. loginSchema: z.object({ email: z.string().email(), @@ -395,6 +407,37 @@ describe('auth.controller', () => { ); }); + test('returns default onboarding state for users without stored onboarding', async () => { + const mockSelect = jest.fn().mockResolvedValue({ + _id: 'dev_id_1', + email: 'test@example.com', + }); + Developer.findById.mockReturnValue({ select: mockSelect }); + + const req = makeReq({}, { _id: 'dev_id_1' }); + const res = makeRes(); + + await authController.getMe(req, res, next); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: { + user: expect.objectContaining({ + onboarding: { + completed: false, + steps: { + projectCreated: false, + collectionCreated: false, + firstApiCall: false, + }, + activationAt: null, + }, + }), + }, + message: 'Success', + }); + }); + test('returns 404 when user does not exist', async () => { Developer.findById.mockReturnValue({ select: jest.fn().mockResolvedValue(null), diff --git a/apps/dashboard-api/src/__tests__/routes.user.test.js b/apps/dashboard-api/src/__tests__/routes.user.test.js new file mode 100644 index 000000000..24f85e602 --- /dev/null +++ b/apps/dashboard-api/src/__tests__/routes.user.test.js @@ -0,0 +1,59 @@ +'use strict'; + +jest.mock('../middlewares/authMiddleware', () => + jest.fn((_req, _res, next) => next()) +); + +jest.mock('../controllers/auth.controller', () => ({ + getMe: jest.fn((_req, res) => res.json({ + success: true, + data: { + user: { + onboarding: { + completed: false, + steps: { + projectCreated: false, + collectionCreated: false, + firstApiCall: false, + }, + activationAt: null, + }, + }, + }, + message: 'Success', + })), +})); + +const express = require('express'); +const request = require('supertest'); +const userRouter = require('../routes/user'); +const authMiddleware = require('../middlewares/authMiddleware'); +const authController = require('../controllers/auth.controller'); + +describe('user routes', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json()); + app.use('/api/user', userRouter); + }); + + test('GET /api/user/me is wired to authenticated getMe', async () => { + const res = await request(app).get('/api/user/me'); + + expect(res.status).toBe(200); + expect(authMiddleware).toHaveBeenCalledTimes(1); + expect(authController.getMe).toHaveBeenCalledTimes(1); + expect(res.body.data.user.onboarding).toEqual({ + completed: false, + steps: { + projectCreated: false, + collectionCreated: false, + firstApiCall: false, + }, + activationAt: null, + }); + }); +}); diff --git a/apps/dashboard-api/src/app.js b/apps/dashboard-api/src/app.js index b93c37a1d..5620cadc9 100644 --- a/apps/dashboard-api/src/app.js +++ b/apps/dashboard-api/src/app.js @@ -98,6 +98,7 @@ app.use(capture({ const authRoute = require('./routes/auth'); +const userRoute = require('./routes/user'); const projectRoute = require('./routes/projects'); const releaseRoute = require('./routes/releases'); const webhookRoute = require('./routes/webhooks'); @@ -108,6 +109,7 @@ const adminMetricsRoute = require('./routes/admin.metrics'); const aiRoute = require('./routes/ai.routes'); app.use('/api/auth', authRoute); +app.use('/api/user', dashboardLimiter, userRoute); app.use('/api/projects', dashboardLimiter, projectRoute); app.use('/api/projects/:projectId/ai', dashboardLimiter, aiRoute); app.use('/api/projects', dashboardLimiter, webhookRoute); diff --git a/apps/dashboard-api/src/controllers/auth.controller.js b/apps/dashboard-api/src/controllers/auth.controller.js index 3ca4b2b38..b3de7149f 100644 --- a/apps/dashboard-api/src/controllers/auth.controller.js +++ b/apps/dashboard-api/src/controllers/auth.controller.js @@ -14,7 +14,8 @@ const { resetPasswordSchema, verifyOtpSchema, AppError, - ApiResponse + ApiResponse, + normalizeOnboarding } = require("@urbackend/common"); const { emitEvent } = require('../utils/emitEvent'); @@ -618,6 +619,7 @@ module.exports.getMe = async (req, res, next) => { const user = await Developer.findById(req.user._id).select("-password -refreshToken"); if (!user) return next(new AppError(404, "User not found")); const userData = typeof user.toObject === 'function' ? user.toObject() : { ...user }; + userData.onboarding = normalizeOnboarding(userData.onboarding); userData.isAdmin = userData.email === process.env.ADMIN_EMAIL; return new ApiResponse({ user: userData }).send(res); } catch (err) { diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 493c3deec..0ad47eb1c 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -15,6 +15,7 @@ const { sanitizeNonEmptyString, } = require("@urbackend/common"); const { generateApiKey, hashApiKey } = require("@urbackend/common"); +const { markDeveloperOnboardingStep } = require("@urbackend/common"); const { z } = require("zod"); const { encrypt, decrypt } = require("@urbackend/common"); const { URL } = require("url"); @@ -302,6 +303,9 @@ module.exports.createProject = async (req, res) => { await session.commitTransaction(); session.endSession(); + markDeveloperOnboardingStep(req.user._id, 'projectCreated').catch((err) => { + console.error('[onboarding] Failed to mark projectCreated:', err.message); + }); emitEvent(req.user._id, 'project_created', { projectName: projectObj.name }, newProject._id); return res.status(201).json(projectObj); } catch (err) { @@ -313,6 +317,9 @@ module.exports.createProject = async (req, res) => { if (err.message && (err.message.includes("Transaction numbers are only allowed") || err.message.includes("buffering timed out"))) { try { const { projectObj, newProject } = await executeOperation(null); + markDeveloperOnboardingStep(req.user._id, 'projectCreated').catch((err) => { + console.error('[onboarding] Failed to mark projectCreated:', err.message); + }); emitEvent(req.user._id, 'project_created', { projectName: projectObj.name }, newProject._id); return res.status(201).json(projectObj); } catch (retryErr) { @@ -837,6 +844,9 @@ module.exports.createCollection = async (req, res) => { delete projectObj.secretKey; delete projectObj.jwtSecret; + markDeveloperOnboardingStep(req.user._id, 'collectionCreated').catch((err) => { + console.error('[onboarding] Failed to mark collectionCreated:', err.message); + }); emitEvent(req.user._id, 'collection_created', { collectionName, isUsersCollection: collectionName === 'users' }, projectId); return res.status(201).json(projectObj); @@ -859,6 +869,9 @@ module.exports.createCollection = async (req, res) => { delete projectObj.secretKey; delete projectObj.jwtSecret; + markDeveloperOnboardingStep(req.user._id, 'collectionCreated').catch((err) => { + console.error('[onboarding] Failed to mark collectionCreated:', err.message); + }); emitEvent(req.user._id, 'collection_created', { collectionName, isUsersCollection: collectionName === 'users' }, projectId); return res.status(201).json(projectObj); diff --git a/apps/dashboard-api/src/routes/user.js b/apps/dashboard-api/src/routes/user.js new file mode 100644 index 000000000..752a71c78 --- /dev/null +++ b/apps/dashboard-api/src/routes/user.js @@ -0,0 +1,8 @@ +const express = require('express'); +const router = express.Router(); +const authorization = require('../middlewares/authMiddleware'); +const { getMe } = require('../controllers/auth.controller'); + +router.get('/me', authorization, getMe); + +module.exports = router; diff --git a/apps/public-api/src/__tests__/api_usage.test.js b/apps/public-api/src/__tests__/api_usage.test.js index 0238d3e9a..fe611350a 100644 --- a/apps/public-api/src/__tests__/api_usage.test.js +++ b/apps/public-api/src/__tests__/api_usage.test.js @@ -3,6 +3,10 @@ const mockLogCreate = jest.fn(); const mockApiAnalyticsCreate = jest.fn(); const mockIncrWithTtlAtomic = jest.fn(); +const mockRedisSet = jest.fn(); +const mockProjectFindById = jest.fn(); +const mockPlatformEventCreate = jest.fn(); +const mockMarkDeveloperOnboardingStep = jest.fn(); jest.mock('@urbackend/common', () => ({ Log: { @@ -11,12 +15,19 @@ jest.mock('@urbackend/common', () => ({ ApiAnalytics: { create: (...args) => mockApiAnalyticsCreate(...args), }, + Project: { + findById: (...args) => mockProjectFindById(...args), + }, + PlatformEvent: { + create: (...args) => mockPlatformEventCreate(...args), + }, redis: { - set: jest.fn().mockResolvedValue(null), + set: (...args) => mockRedisSet(...args), }, getDayKey: () => '2026-06-08', DEFAULT_DAILY_TTL_SECONDS: 86400, incrWithTtlAtomic: (...args) => mockIncrWithTtlAtomic(...args), + markDeveloperOnboardingStep: (...args) => mockMarkDeveloperOnboardingStep(...args), })); const { logger } = require('../middlewares/api_usage'); @@ -27,6 +38,8 @@ describe('api_usage middleware', () => { beforeEach(() => { jest.clearAllMocks(); + mockRedisSet.mockResolvedValue(null); + mockMarkDeveloperOnboardingStep.mockResolvedValue(null); req = { project: { _id: 'test_project_id' }, method: 'GET', @@ -139,4 +152,42 @@ describe('api_usage middleware', () => { consoleErrorSpy.mockRestore(); }); + + test('marks onboarding on first successful API response', async () => { + mockLogCreate.mockResolvedValue({ _id: 'log_id' }); + mockApiAnalyticsCreate.mockResolvedValue({ _id: 'analytics_id' }); + mockRedisSet.mockResolvedValue('OK'); + mockProjectFindById.mockReturnValue({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue({ owner: 'dev_id_1' }), + }); + mockMarkDeveloperOnboardingStep.mockResolvedValue({ + completed: false, + steps: { + projectCreated: false, + collectionCreated: false, + firstApiCall: true, + }, + activationAt: new Date('2026-06-08T00:00:00.000Z'), + }); + mockPlatformEventCreate.mockResolvedValue({ _id: 'event_id' }); + + logger(req, res, next); + await finishCallback(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockRedisSet).toHaveBeenCalledWith( + 'project:activation:first_api_success:test_project_id', + '1', + 'EX', + 63072000, + 'NX' + ); + expect(mockMarkDeveloperOnboardingStep).toHaveBeenCalledWith('dev_id_1', 'firstApiCall'); + expect(mockPlatformEventCreate).toHaveBeenCalledWith(expect.objectContaining({ + developerId: 'dev_id_1', + projectId: 'test_project_id', + event: 'first_api_success', + })); + }); }); diff --git a/apps/public-api/src/middlewares/api_usage.js b/apps/public-api/src/middlewares/api_usage.js index 3d20b3b88..8a11f9232 100644 --- a/apps/public-api/src/middlewares/api_usage.js +++ b/apps/public-api/src/middlewares/api_usage.js @@ -90,9 +90,10 @@ const logger = (req, res, next) => { 'NX' ); if (isFirst) { - const { Project, PlatformEvent } = require('@urbackend/common'); + const { Project, PlatformEvent, markDeveloperOnboardingStep } = require('@urbackend/common'); const proj = await Project.findById(req.project._id).select('owner').lean(); if (proj?.owner) { + await markDeveloperOnboardingStep(proj.owner, 'firstApiCall'); await PlatformEvent.create({ developerId: proj.owner, projectId: req.project._id, diff --git a/apps/web-dashboard/src/components/Dashboard/UsageProgressBar.jsx b/apps/web-dashboard/src/components/Dashboard/UsageProgressBar.jsx index 62339cdb4..84a626c27 100644 --- a/apps/web-dashboard/src/components/Dashboard/UsageProgressBar.jsx +++ b/apps/web-dashboard/src/components/Dashboard/UsageProgressBar.jsx @@ -1,12 +1,14 @@ import React from 'react'; +import { getProgressWidth, getUsagePercentage } from '../../utils/quota'; const UsageProgressBar = ({ label, used, limit, unit = 'MB', formatValue: customFormat, unlimited = false, tooltip = '' }) => { const isUnlimited = unlimited || limit === -1; - const percentage = isUnlimited ? 0 : Math.min(Math.round((used / limit) * 100), 100); + const percentage = isUnlimited ? 0 : getUsagePercentage(used, limit); + const hasPercentage = Number.isFinite(percentage); let statusClass = ''; - if (percentage > 80) statusClass = 'danger'; - else if (percentage > 60) statusClass = 'warning'; + if (hasPercentage && percentage > 80) statusClass = 'danger'; + else if (hasPercentage && percentage > 60) statusClass = 'warning'; const format = (value) => { if (customFormat) return customFormat(value); @@ -21,13 +23,13 @@ const UsageProgressBar = ({ label, used, limit, unit = 'MB', formatValue: custom
{label} - {isUnlimited ? '∞' : `${percentage}%`} + {isUnlimited ? '∞' : (hasPercentage ? `${percentage}%` : '—')}
{!isUnlimited && (
)} diff --git a/apps/web-dashboard/src/components/Layout/Sidebar.jsx b/apps/web-dashboard/src/components/Layout/Sidebar.jsx index b537816a6..c1e6b25f3 100644 --- a/apps/web-dashboard/src/components/Layout/Sidebar.jsx +++ b/apps/web-dashboard/src/components/Layout/Sidebar.jsx @@ -17,11 +17,16 @@ function Sidebar({ logo, isOpen, onClose }) { if (window.innerWidth <= 768) onClose(); }; + const navA11yProps = (label) => ({ + 'aria-label': label, + title: label, + }); + return (