From 1420bb28cec9c0e20b4d0645a58e436135f87c83 Mon Sep 17 00:00:00 2001 From: "IceHe.xyz" Date: Mon, 30 May 2022 13:51:58 +0800 Subject: [PATCH] feat(core,shared): get /dashboard/users/active (#953) * feat(core,shared): get /dashboard/users/active * refactor(core): get /dashboard/users/active * refactor(core): get /dashboard/users/active * refactor(core): simplify GET /dashboard/users/active * refactor(core): simplify dashboardRoutes --- packages/core/src/queries/log.ts | 31 ++++++ packages/core/src/routes/dashboard.test.ts | 123 ++++++++++++++++++++- packages/core/src/routes/dashboard.ts | 92 ++++++++++++++- packages/shared/src/regex.ts | 1 + 4 files changed, 241 insertions(+), 6 deletions(-) diff --git a/packages/core/src/queries/log.ts b/packages/core/src/queries/log.ts index bd5552f91..c47f24313 100644 --- a/packages/core/src/queries/log.ts +++ b/packages/core/src/queries/log.ts @@ -72,3 +72,34 @@ export const getDailyNewUserCountsByTimeInterval = async ( and ${fields.payload}->>'result' = 'Success' group by date(${fields.createdAt}) `); + +// The active user should exchange the tokens by the authorization code (i.e. sign-in) +// or exchange the access token, which will expire in 2 hours, by the refresh token. +const activeUserLogTypes: LogType[] = ['CodeExchangeToken', 'RefreshTokenExchangeToken']; + +export const getDailyActiveUserCountsByTimeInterval = async ( + startTimeExclusive: number, + endTimeInclusive: number +) => + envSet.pool.any<{ date: string; count: number }>(sql` + select date(${fields.createdAt}), count(distinct(${fields.payload}->>'userId')) + from ${table} + where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000) + and ${fields.createdAt} <= to_timestamp(${endTimeInclusive}::double precision / 1000) + and ${fields.type} in (${sql.join(activeUserLogTypes, sql`, `)}) + and ${fields.payload}->>'result' = 'Success' + group by date(${fields.createdAt}) + `); + +export const countActiveUsersByTimeInterval = async ( + startTimeExclusive: number, + endTimeInclusive: number +) => + envSet.pool.one<{ count: number }>(sql` + select count(distinct(${fields.payload}->>'userId')) + from ${table} + where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000) + and ${fields.createdAt} <= to_timestamp(${endTimeInclusive}::double precision / 1000) + and ${fields.type} in (${sql.join(activeUserLogTypes, sql`, `)}) + and ${fields.payload}->>'result' = 'Success' + `); diff --git a/packages/core/src/routes/dashboard.test.ts b/packages/core/src/routes/dashboard.test.ts index 86d0f382a..607926674 100644 --- a/packages/core/src/routes/dashboard.test.ts +++ b/packages/core/src/routes/dashboard.test.ts @@ -23,16 +23,38 @@ const mockDailyNewUserCounts = [ { date: '2022-05-14', count: 14 }, ]; +const mockDailyActiveUserCounts = [ + { date: '2022-05-01', count: 501 }, + { date: '2022-05-23', count: 523 }, + { date: '2022-05-29', count: 529 }, + { date: '2022-05-30', count: 530 }, +]; + +const mockActiveUserCount = 1000; + +/* eslint-disable @typescript-eslint/no-unused-vars */ const getDailyNewUserCountsByTimeInterval = jest.fn( - // eslint-disable-next-line @typescript-eslint/no-unused-vars async (startTimeExclusive: number, endTimeInclusive: number) => mockDailyNewUserCounts ); +const getDailyActiveUserCountsByTimeInterval = jest.fn( + async (startTimeExclusive: number, endTimeInclusive: number) => mockDailyActiveUserCounts +); +const countActiveUsersByTimeInterval = jest.fn( + async (startTimeExclusive: number, endTimeInclusive: number) => ({ count: mockActiveUserCount }) +); +/* eslint-enable @typescript-eslint/no-unused-vars */ jest.mock('@/queries/log', () => ({ getDailyNewUserCountsByTimeInterval: async ( startTimeExclusive: number, endTimeInclusive: number ) => getDailyNewUserCountsByTimeInterval(startTimeExclusive, endTimeInclusive), + getDailyActiveUserCountsByTimeInterval: async ( + startTimeExclusive: number, + endTimeInclusive: number + ) => getDailyActiveUserCountsByTimeInterval(startTimeExclusive, endTimeInclusive), + countActiveUsersByTimeInterval: async (startTimeExclusive: number, endTimeInclusive: number) => + countActiveUsersByTimeInterval(startTimeExclusive, endTimeInclusive), })); describe('dashboardRoutes', () => { @@ -83,4 +105,103 @@ describe('dashboardRoutes', () => { }); }); }); + + describe('GET /dashboard/users/active', () => { + const mockToday = '2022-05-30'; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date(mockToday)); + }); + + it('should fail when the parameter `date` does not match the date regex', async () => { + const response = await logRequest.get('/dashboard/users/active?date=2022.5.1'); + expect(response.status).toEqual(400); + }); + + it('should call getDailyActiveUserCountsByTimeInterval with the time interval (2022-05-31, 2022-06-30] when the parameter `date` is 2022-06-30', async () => { + const targetDate = '2022-06-30'; + await logRequest.get(`/dashboard/users/active?date=${targetDate}`); + expect(getDailyActiveUserCountsByTimeInterval).toHaveBeenCalledWith( + dayjs('2022-05-31').endOf('day').valueOf(), + dayjs(targetDate).endOf('day').valueOf() + ); + }); + + it('should call getDailyActiveUserCountsByTimeInterval with the time interval (30 days ago, tomorrow] when there is no parameter `date`', async () => { + await logRequest.get('/dashboard/users/active'); + expect(getDailyActiveUserCountsByTimeInterval).toHaveBeenCalledWith( + dayjs('2022-04-30').endOf('day').valueOf(), + dayjs(mockToday).endOf('day').valueOf() + ); + }); + + it('should call countActiveUsersByTimeInterval with correct parameters when the parameter `date` is 2022-06-30', async () => { + const targetDate = '2022-06-30'; + await logRequest.get(`/dashboard/users/active?date=${targetDate}`); + expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( + 1, + dayjs('2022-06-16').endOf('day').valueOf(), + dayjs('2022-06-23').endOf('day').valueOf() + ); + expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( + 2, + dayjs('2022-06-23').endOf('day').valueOf(), + dayjs(targetDate).endOf('day').valueOf() + ); + expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( + 3, + dayjs('2022-05-01').endOf('day').valueOf(), + dayjs('2022-05-31').endOf('day').valueOf() + ); + expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( + 4, + dayjs('2022-05-31').endOf('day').valueOf(), + dayjs(targetDate).endOf('day').valueOf() + ); + }); + + it('should call countActiveUsersByTimeInterval with correct parameters when there is no parameter `date`', async () => { + await logRequest.get('/dashboard/users/active'); + expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( + 1, + dayjs('2022-05-16').endOf('day').valueOf(), + dayjs('2022-05-23').endOf('day').valueOf() + ); + expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( + 2, + dayjs('2022-05-23').endOf('day').valueOf(), + dayjs(mockToday).endOf('day').valueOf() + ); + expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( + 3, + dayjs('2022-03-31').endOf('day').valueOf(), + dayjs('2022-04-30').endOf('day').valueOf() + ); + expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( + 4, + dayjs('2022-04-30').endOf('day').valueOf(), + dayjs(mockToday).endOf('day').valueOf() + ); + }); + + it('should return correct response', async () => { + const response = await logRequest.get('/dashboard/users/active'); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + dauCurve: mockDailyActiveUserCounts, + dau: { + count: 530, + delta: 1, + }, + wau: { + count: 1000, + delta: 0, + }, + mau: { + count: 1000, + delta: 0, + }, + }); + }); + }); }); diff --git a/packages/core/src/routes/dashboard.ts b/packages/core/src/routes/dashboard.ts index 6776370de..3330c2dfc 100644 --- a/packages/core/src/routes/dashboard.ts +++ b/packages/core/src/routes/dashboard.ts @@ -1,12 +1,21 @@ +import { dateRegex } from '@logto/shared'; import dayjs, { Dayjs } from 'dayjs'; +import { object, string } from 'zod'; -import { getDailyNewUserCountsByTimeInterval } from '@/queries/log'; +import koaGuard from '@/middleware/koa-guard'; +import { + countActiveUsersByTimeInterval, + getDailyActiveUserCountsByTimeInterval, + getDailyNewUserCountsByTimeInterval, +} from '@/queries/log'; import { countUsers } from '@/queries/user'; import { AuthedRouter } from './types'; const getDateString = (day: Dayjs) => day.format('YYYY-MM-DD'); +const lastTimestampOfDay = (day: Dayjs) => day.endOf('day').valueOf(); + const indicesFrom0To6 = [...Array.from({ length: 7 }).keys()]; export default function dashboardRoutes(router: T) { @@ -19,11 +28,10 @@ export default function dashboardRoutes(router: T) { router.get('/dashboard/users/new', async (ctx, next) => { const today = dayjs(); - const fourteenDaysAgo = today.subtract(14, 'day'); const dailyNewUserCounts = await getDailyNewUserCountsByTimeInterval( - // Time interval: (14 days ago 23:59:59.999, today 23:59:59.999] - fourteenDaysAgo.endOf('day').valueOf(), - today.endOf('day').valueOf() + // (14 days ago 23:59:59.999, today 23:59:59.999] + lastTimestampOfDay(today.subtract(14, 'day')), + lastTimestampOfDay(today) ); const last14DaysNewUserCounts = new Map( @@ -56,4 +64,78 @@ export default function dashboardRoutes(router: T) { return next(); }); + + router.get( + '/dashboard/users/active', + koaGuard({ + query: object({ date: string().regex(dateRegex).optional() }), + }), + async (ctx, next) => { + const { + query: { date }, + } = ctx.guard; + + const targetDay = date ? dayjs(date) : dayjs(); // Defaults to today + const [ + // DAU: Daily Active User + last30DauCounts, + // WAU: Weekly Active User + { count: previousWAU }, + { count: wau }, + // MAU: Monthly Active User + { count: previousMAU }, + { count: mau }, + ] = await Promise.all([ + getDailyActiveUserCountsByTimeInterval( + // (30 days ago 23:59:59.999, target day 23:59:59.999] + lastTimestampOfDay(targetDay.subtract(30, 'day')), + lastTimestampOfDay(targetDay) + ), + countActiveUsersByTimeInterval( + // (14 days ago 23:59:59.999, 7 days ago 23:59:59.999] + lastTimestampOfDay(targetDay.subtract(14, 'day')), + lastTimestampOfDay(targetDay.subtract(7, 'day')) + ), + countActiveUsersByTimeInterval( + // (7 days ago 23:59:59.999, target day 23:59:59.999] + lastTimestampOfDay(targetDay.subtract(7, 'day')), + lastTimestampOfDay(targetDay) + ), + countActiveUsersByTimeInterval( + // (60 days ago 23:59:59.999, 30 days ago 23:59:59.999] + lastTimestampOfDay(targetDay.subtract(60, 'day')), + lastTimestampOfDay(targetDay.subtract(30, 'day')) + ), + countActiveUsersByTimeInterval( + // (30 days ago 23:59:59.999, target day 23:59:59.999] + lastTimestampOfDay(targetDay.subtract(30, 'day')), + lastTimestampOfDay(targetDay) + ), + ]); + + const previousDate = getDateString(targetDay.subtract(1, 'day')); + const targetDate = getDateString(targetDay); + + const previousDAU = last30DauCounts.find(({ date }) => date === previousDate)?.count ?? 0; + const dau = last30DauCounts.find(({ date }) => date === targetDate)?.count ?? 0; + + ctx.body = { + dauCurve: last30DauCounts, + dau: { + count: dau, + delta: dau - previousDAU, + }, + wau: { + count: wau, + delta: wau - previousWAU, + }, + mau: { + count: mau, + delta: mau - previousMAU, + }, + }; + + return next(); + } + ); } diff --git a/packages/shared/src/regex.ts b/packages/shared/src/regex.ts index 4ffa39a10..60996463a 100644 --- a/packages/shared/src/regex.ts +++ b/packages/shared/src/regex.ts @@ -5,3 +5,4 @@ export const nameRegEx = /^.+$/; export const passwordRegEx = /^.{6,}$/; export const redirectUriRegEx = /^https?:\/\//; export const hexColorRegEx = /^#[\da-f]{3}([\da-f]{3})?$/i; +export const dateRegex = /^\d{4}(-\d{2}){2}/;