diff --git a/packages/core/src/queries/log.ts b/packages/core/src/queries/log.ts index 3885dc2d1..bd5552f91 100644 --- a/packages/core/src/queries/log.ts +++ b/packages/core/src/queries/log.ts @@ -1,4 +1,4 @@ -import { CreateLog, Log, Logs } from '@logto/schemas'; +import { CreateLog, Log, Logs, LogType } from '@logto/schemas'; import { sql } from 'slonik'; import { buildInsertInto } from '@/database/insert-into'; @@ -51,3 +51,24 @@ export const findLogById = async (id: string) => from ${table} where ${fields.id}=${id} `); + +const registerLogTypes: LogType[] = [ + 'RegisterUsernamePassword', + 'RegisterEmail', + 'RegisterSms', + 'RegisterSocial', +]; + +export const getDailyNewUserCountsByTimeInterval = async ( + startTimeExclusive: number, + endTimeInclusive: number +) => + envSet.pool.any<{ date: string; count: number }>(sql` + select date(${fields.createdAt}), count(*) + 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(registerLogTypes, sql`, `)}) + and ${fields.payload}->>'result' = 'Success' + group by date(${fields.createdAt}) + `); diff --git a/packages/core/src/routes/dashboard.test.ts b/packages/core/src/routes/dashboard.test.ts index 636689515..86d0f382a 100644 --- a/packages/core/src/routes/dashboard.test.ts +++ b/packages/core/src/routes/dashboard.test.ts @@ -1,3 +1,5 @@ +import dayjs from 'dayjs'; + import dashboardRoutes from '@/routes/dashboard'; import { createRequester } from '@/utils/test-utils'; @@ -8,6 +10,31 @@ jest.mock('@/queries/user', () => ({ countUsers: async () => countUsers(), })); +const mockDailyNewUserCounts = [ + { date: '2022-05-01', count: 1 }, + { date: '2022-05-02', count: 2 }, + { date: '2022-05-03', count: 3 }, + { date: '2022-05-06', count: 6 }, + { date: '2022-05-07', count: 7 }, + { date: '2022-05-08', count: 8 }, + { date: '2022-05-09', count: 9 }, + { date: '2022-05-10', count: 10 }, + { date: '2022-05-13', count: 13 }, + { date: '2022-05-14', count: 14 }, +]; + +const getDailyNewUserCountsByTimeInterval = jest.fn( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async (startTimeExclusive: number, endTimeInclusive: number) => mockDailyNewUserCounts +); + +jest.mock('@/queries/log', () => ({ + getDailyNewUserCountsByTimeInterval: async ( + startTimeExclusive: number, + endTimeInclusive: number + ) => getDailyNewUserCountsByTimeInterval(startTimeExclusive, endTimeInclusive), +})); + describe('dashboardRoutes', () => { const logRequest = createRequester({ authedRoutes: dashboardRoutes }); @@ -27,4 +54,33 @@ describe('dashboardRoutes', () => { expect(response.body).toEqual({ totalUserCount }); }); }); + + describe('GET /dashboard/users/new', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2022-05-14')); + }); + + it('should call getDailyNewUserCountsByTimeInterval with the time interval (14 days ago 23:59:59.999, today 23:59:59.999]', async () => { + await logRequest.get('/dashboard/users/new'); + expect(getDailyNewUserCountsByTimeInterval).toHaveBeenCalledWith( + dayjs().endOf('day').subtract(14, 'day').valueOf(), + dayjs().endOf('day').valueOf() + ); + }); + + it('should return correct response', async () => { + const response = await logRequest.get('/dashboard/users/new'); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + today: { + count: 14, + delta: 1, + }, + last7Days: { + count: 54, + delta: 35, + }, + }); + }); + }); }); diff --git a/packages/core/src/routes/dashboard.ts b/packages/core/src/routes/dashboard.ts index 82fbf35ae..6776370de 100644 --- a/packages/core/src/routes/dashboard.ts +++ b/packages/core/src/routes/dashboard.ts @@ -1,7 +1,14 @@ +import dayjs, { Dayjs } from 'dayjs'; + +import { getDailyNewUserCountsByTimeInterval } from '@/queries/log'; import { countUsers } from '@/queries/user'; import { AuthedRouter } from './types'; +const getDateString = (day: Dayjs) => day.format('YYYY-MM-DD'); + +const indicesFrom0To6 = [...Array.from({ length: 7 }).keys()]; + export default function dashboardRoutes(router: T) { router.get('/dashboard/users/total', async (ctx, next) => { const { count: totalUserCount } = await countUsers(); @@ -9,4 +16,44 @@ export default function dashboardRoutes(router: T) { return next(); }); + + 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() + ); + + const last14DaysNewUserCounts = new Map( + dailyNewUserCounts.map(({ date, count }) => [date, count]) + ); + + const todayNewUserCount = last14DaysNewUserCounts.get(getDateString(today)) ?? 0; + const yesterday = today.subtract(1, 'day'); + const yesterdayNewUserCount = last14DaysNewUserCounts.get(getDateString(yesterday)) ?? 0; + const todayDelta = todayNewUserCount - yesterdayNewUserCount; + + const last7DaysNewUserCount = indicesFrom0To6 + .map((index) => getDateString(today.subtract(index, 'day'))) + .reduce((sum, date) => sum + (last14DaysNewUserCounts.get(date) ?? 0), 0); + const newUserCountFrom13DaysAgoTo7DaysAgo = indicesFrom0To6 + .map((index) => getDateString(today.subtract(7 + index, 'day'))) + .reduce((sum, date) => sum + (last14DaysNewUserCounts.get(date) ?? 0), 0); + const last7DaysDelta = last7DaysNewUserCount - newUserCountFrom13DaysAgoTo7DaysAgo; + + ctx.body = { + today: { + count: todayNewUserCount, + delta: todayDelta, + }, + last7Days: { + count: last7DaysNewUserCount, + delta: last7DaysDelta, + }, + }; + + return next(); + }); }