0
Fork 0
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:
IceHe.xyz 2022-05-30 13:51:58 +08:00 committed by GitHub
parent eb3f0cbf5b
commit 1420bb28ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 241 additions and 6 deletions

View file

@ -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'
`);

View file

@ -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,
},
});
});
});
});

View file

@ -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();
}
);
}

View file

@ -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}/;