mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
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
This commit is contained in:
parent
eb3f0cbf5b
commit
1420bb28ce
4 changed files with 241 additions and 6 deletions
|
@ -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'
|
||||
`);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<T extends AuthedRouter>(router: T) {
|
||||
|
@ -19,11 +28,10 @@ export default function dashboardRoutes<T extends AuthedRouter>(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<T extends AuthedRouter>(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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}/;
|
||||
|
|
Loading…
Reference in a new issue