Skip to content

Commit

Permalink
Merge pull request #989 from parlemonde/dashboard-to-staging
Browse files Browse the repository at this point in the history
Dashboard to staging
  • Loading branch information
DavidRobertAnsart authored Sep 21, 2024
2 parents 7ce9e78 + 1085609 commit 90cf6d4
Show file tree
Hide file tree
Showing 36 changed files with 1,189 additions and 361 deletions.
54 changes: 27 additions & 27 deletions docker-compose-windows.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
services:
mysql:
image: mysql:8
environment:
- MYSQL_ROOT_PASSWORD=my-secret-pw
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
timeout: 20s
retries: 10
ports:
- '3306:3306'
- '33060:33060'
volumes:
- ./.mysql-data:/var/lib/mysql
minio:
image: minio/minio
ports:
- '9000:9000'
- '9090:9090'
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
- MINIO_ACCESS_KEY=minio
- MINIO_SECRET_KEY=minio123
volumes:
- ./.minio-data:/data
command: server --console-address ":9090" /data
# services:
# mysql:
# image: mysql:8
# environment:
# - MYSQL_ROOT_PASSWORD=my-secret-pw
# healthcheck:
# test: [ 'CMD', 'mysqladmin', 'ping', '-h', 'localhost' ]
# timeout: 20s
# retries: 10
# ports:
# - '3306:3306'
# - '33060:33060'
# volumes:
# - ./.mysql-data:/var/lib/mysql
# minio:
# image: minio/minio
# ports:
# - '9000:9000'
# - '9090:9090'
# environment:
# - MINIO_ROOT_USER=minioadmin
# - MINIO_ROOT_PASSWORD=minioadmin
# - MINIO_ACCESS_KEY=minio
# - MINIO_SECRET_KEY=minio123
# volumes:
# - ./.minio-data:/data
# command: server --console-address ":9090" /data
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ services:
depends_on:
mysql:
condition: service_healthy
command: [ 'yarn', 'run', 'migration:run-dev' ]
command: ['yarn', 'run', 'migration:run-dev']
mysql:
image: mysql:8
environment:
- MYSQL_ROOT_PASSWORD=my-secret-pw
healthcheck:
test: [ 'CMD', 'mysqladmin', 'ping', '-h', 'localhost' ]
test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
timeout: 20s
retries: 10
ports:
Expand Down
Binary file added public/static-images/pelico-question.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 24 additions & 2 deletions server/controllers/analytic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Between } from 'typeorm';

import type { AnalyticData, NavigationPerf, BrowserPerf } from '../../types/analytics.type';
import { AnalyticSession, AnalyticPageView, AnalyticPerformance } from '../entities/analytic';
import { UserType } from '../entities/user';
import { User, UserType } from '../entities/user';
import { AppError, ErrorCode, handleErrors } from '../middlewares/handleErrors';
import { generateTemporaryToken, getQueryString } from '../utils';
import { AppDataSource } from '../utils/data-source';
Expand Down Expand Up @@ -179,6 +179,8 @@ analyticController.get({ path: '', userType: UserType.ADMIN }, async (req, res)

type AddAnalytic = {
sessionId: string;
userId?: number;
phase: number;
event: string;
location: string;
referrer?: string | null;
Expand All @@ -196,6 +198,14 @@ const ADD_ANALYTIC_SCHEMA: JSONSchemaType<AddAnalytic> = {
type: 'string',
nullable: false,
},
userId: {
type: 'number',
nullable: true,
},
phase: {
type: 'number',
nullable: false,
},
event: {
type: 'string',
nullable: false,
Expand Down Expand Up @@ -245,6 +255,7 @@ analyticController.router.post(
useragent.express(),
handleErrors(async (req, res) => {
const data = req.body;

if (!addAnalyticValidator(data)) {
sendInvalidDataError(addAnalyticValidator);
return;
Expand All @@ -265,10 +276,17 @@ analyticController.router.post(
}

// Retrieve current user session or save the new one.
let sessionCount = await AppDataSource.getRepository(AnalyticSession).count({ where: { id: data.sessionId } });
// eslint-disable-next-line prefer-const
let [sessionCount, userPhase] = await Promise.all([
AppDataSource.getRepository(AnalyticSession).count({ where: { id: data.sessionId } }),
AppDataSource.getRepository(User).createQueryBuilder('user').select('user.firstlogin').where({ id: data.userId }).getRawOne(),
]);

if (sessionCount === 0 && data.event === 'pageview' && data.params?.isInitial) {
const session = new AnalyticSession();
session.id = data.sessionId;
// TODO à améliorer
// session.uniqueId = data.userId ? `${uniqueSessionId}-${data.userId}` : uniqueSessionId;
session.uniqueId = uniqueSessionId;
session.date = new Date();
session.browserName = req.useragent?.browser ?? '';
Expand All @@ -278,6 +296,10 @@ analyticController.router.post(
session.width = data.width || 0;
session.duration = null;
session.initialPage = data.location;
session.userId = data.userId ?? null;
// TODO debug phase
session.phase = userPhase ? userPhase.firstlogin : 0;

await AppDataSource.getRepository(AnalyticSession).save(session);
sessionCount = 1;
}
Expand Down
2 changes: 2 additions & 0 deletions server/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { statisticsController } from './statistics';
import { storyController } from './story';
import { studentController } from './student';
import { teacherController } from './teacher';
import { teamCommentController } from './teamComment';
import { userController } from './user';
import { videoController } from './video';
import { villageController } from './village';
Expand Down Expand Up @@ -45,6 +46,7 @@ const controllers = [
studentController,
featureFlagController,
statisticsController,
teamCommentController,
pelicoController,
mediathequeController,
];
Expand Down
139 changes: 37 additions & 102 deletions server/controllers/statistics.ts
Original file line number Diff line number Diff line change
@@ -1,110 +1,45 @@
import { AnalyticSession } from '../entities/analytic';
import { Classroom } from '../entities/classroom';
import { AppDataSource } from '../utils/data-source';
import type { Request } from 'express';

import {
getClassroomsInfos,
getConnectedClassroomsCount,
getContributedClassroomsCount,
getRegisteredClassroomsCount,
} from '../stats/classroomStats';
import {
getAverageConnections,
getAverageDuration,
getMaxConnections,
getMaxDuration,
getMedianConnections,
getMedianDuration,
getMinConnections,
getMinDuration,
} from '../stats/sessionStats';
import { Controller } from './controller';

export const statisticsController = new Controller('/statistics');

const classroomRepository = AppDataSource.getRepository(Classroom);
const analyticSessionRepository = AppDataSource.getRepository(AnalyticSession);

statisticsController.get({ path: '/classrooms' }, async (_req, res) => {
const classroomsData = await classroomRepository
.createQueryBuilder('classroom')
.leftJoin('classroom.village', 'village')
.leftJoin('classroom.user', 'user')
.addSelect('classroom.id', 'classroomId')
.addSelect('classroom.name', 'classroomName')
.addSelect('classroom.countryCode', 'classroomCountryCode')
.addSelect('village.id', 'villageId')
.addSelect('village.name', 'villageName')
.addSelect('user.id', 'userId')
.addSelect('user.firstname', 'userFirstname')
.addSelect('user.lastname', 'userLastname')
.addSelect(
`(SELECT COUNT(comment.id)
FROM comment
WHERE comment.userId = user.id) AS commentsCount`,
)
.addSelect(
`(SELECT COUNT(video.id)
FROM video
WHERE video.userId = user.id) AS videosCount`,
)
.addSelect(
`(SELECT JSON_ARRAYAGG(
JSON_OBJECT(
'phase', ac.phase,
'activities', ac.activities
)
)
FROM (
SELECT
activity.phase,
JSON_ARRAYAGG(
JSON_OBJECT(
'type', activity.type,
'count', activity.totalActivities
)
) AS activities
FROM (
SELECT
activity.phase,
activity.type,
COUNT(activity.id) AS totalActivities
FROM activity
WHERE activity.userId = user.id
AND activity.villageId = classroom.villageId
AND activity.deleteDate IS NULL
GROUP BY activity.phase, activity.type
) AS activity
GROUP BY activity.phase
) AS ac
) AS activitiesCount`,
)
.groupBy('classroom.id')
.addGroupBy('user.id')
.getRawMany();
statisticsController.get({ path: '/sessions/:phase' }, async (req: Request, res) => {
const phase = req.params.phase ? parseInt(req.params.phase) : null;

res.sendJSON(
classroomsData.map((classroom) => ({
...classroom,
commentsCount: parseInt(classroom.commentsCount, 10),
videosCount: parseInt(classroom.videosCount, 10),
})),
);
res.sendJSON({
minDuration: await getMinDuration(), // TODO - add phase
maxDuration: await getMaxDuration(), // TODO - add phase
averageDuration: await getAverageDuration(), // TODO - add phase
medianDuration: await getMedianDuration(), // TODO - add phase
minConnections: await getMinConnections(), // TODO - add phase
maxConnections: await getMaxConnections(), // TODO - add phase
averageConnections: await getAverageConnections(), // TODO - add phase
medianConnections: await getMedianConnections(), // TODO - add phase
registeredClassroomsCount: await getRegisteredClassroomsCount(),
connectedClassroomsCount: await getConnectedClassroomsCount(), // TODO - add phase
contributedClassroomsCount: await getContributedClassroomsCount(phase),
});
});

statisticsController.get({ path: '/connections' }, async (_req, res) => {
const durationThreshold = 60;
const connectionsStats = await analyticSessionRepository
.createQueryBuilder('analytic_session')
.select('MIN(DISTINCT(analytic_session.duration))', 'minDuration')
.addSelect('COUNT(DISTINCT uniqueId)', 'connectedClassroomsCount')
.addSelect('(SELECT COUNT(DISTINCT id) FROM classroom)', 'registeredClassroomsCount')
.addSelect('(SELECT COUNT(DISTINCT userId) FROM activity)', 'contributedClassroomsCount')
.addSelect('MAX(DISTINCT(analytic_session.duration))', 'maxDuration')
.addSelect('ROUND(AVG(CASE WHEN analytic_session.duration >= :minDuration THEN analytic_session.duration ELSE NULL END), 0)', 'averageDuration')
.addSelect(
`(SELECT duration FROM (SELECT duration, ROW_NUMBER() OVER (ORDER BY duration) AS medianIdx FROM (SELECT DISTINCT duration FROM analytic_session WHERE duration IS NOT NULL AND duration >= :minDuration) AS sub) AS d, (SELECT COUNT(DISTINCT duration) AS cnt FROM analytic_session WHERE duration IS NOT NULL AND duration >= :minDuration) AS total_count WHERE d.medianIdx = (total_count.cnt DIV 2))`,
'medianDuration',
)
.addSelect('MIN(sub.occurrence_count)', 'minConnections')
.addSelect('MAX(sub.occurrence_count)', 'maxConnections')
.addSelect('ROUND(AVG(sub.occurrence_count), 0)', 'averageConnections')
.addSelect(
`(SELECT connection_count FROM (SELECT connection_count, ROW_NUMBER() OVER (ORDER BY connection_count) AS medianIdx FROM (SELECT COUNT(*) AS connection_count FROM analytic_session GROUP BY uniqueId) AS sub) AS d, (SELECT COUNT(*) AS cnt FROM (SELECT COUNT(*) AS connection_count FROM analytic_session GROUP BY uniqueId) AS sub_count) AS total_count WHERE d.medianIdx = (total_count.cnt DIV 2))`,
'medianConnections',
)
.from((subQuery) => {
return subQuery.subQuery().select('COUNT(*)', 'occurrence_count').from(AnalyticSession, 'analytic_session').groupBy('uniqueId');
}, 'sub')
.where('analytic_session.duration >= :minDuration', { minDuration: durationThreshold })
.getRawOne();

for (const property in connectionsStats) {
connectionsStats[property] = typeof connectionsStats[property] === 'string' ? parseInt(connectionsStats[property]) : connectionsStats[property];
}

res.sendJSON(connectionsStats);
statisticsController.get({ path: '/classrooms' }, async (_req, res) => {
res.sendJSON({
classrooms: await getClassroomsInfos(),
});
});
33 changes: 33 additions & 0 deletions server/controllers/teamComment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { NextFunction, Request, Response } from 'express';

import { TeamComment } from '../entities/teamComment';
import { UserType } from '../entities/user';
import { AppDataSource } from '../utils/data-source';
import { Controller } from './controller';

const teamCommentController = new Controller('/team-comments');

// --- Get all comments. ---
teamCommentController.get({ path: '', userType: UserType.ADMIN }, async (req: Request, res: Response) => {
const teamComments = await AppDataSource.getRepository(TeamComment).find();
res.sendJSON(teamComments);
});

// --- Edit one comment. ---
teamCommentController.put({ path: '/:commentId', userType: UserType.ADMIN }, async (req: Request, res: Response, next: NextFunction) => {
const data = req.body;
const id = parseInt(req.params.commentId, 10) ?? 0;
const teamComment = await AppDataSource.getRepository(TeamComment).findOne({ where: { id } });
if (!teamComment) {
next();
return;
}

const updatedTeamComment = new TeamComment();
updatedTeamComment.id = id;
updatedTeamComment.text = data.text;
await AppDataSource.getRepository(TeamComment).save(updatedTeamComment);
res.sendJSON(updatedTeamComment);
});

export { teamCommentController };
11 changes: 11 additions & 0 deletions server/entities/analytic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
NavigationPerf,
BrowserPerf,
} from '../../types/analytics.type';
import { User } from './user';

@Entity()
export class AnalyticSession implements AnalyticSessionInterface {
Expand Down Expand Up @@ -39,6 +40,16 @@ export class AnalyticSession implements AnalyticSessionInterface {

@Column({ type: 'varchar', length: 255 })
initialPage: string;

@ManyToOne(() => User, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'userId' })
public user: User;

@Column({ type: 'integer', nullable: true })
public userId: number | null;

@Column({ type: 'integer', nullable: true })
public phase: number | null;
}

@Entity()
Expand Down
21 changes: 21 additions & 0 deletions server/entities/teamComment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';

import type { TeamCommentInterface, TeamCommentType } from '../../types/teamComment.type';

@Entity()
export class TeamComment implements TeamCommentInterface {
@PrimaryGeneratedColumn()
public id: number;

@Column({ type: 'tinyint' })
public type: TeamCommentType;

@CreateDateColumn()
public createDate: Date;

@UpdateDateColumn()
public updateDate: Date;

@Column({ type: 'text' })
public text: string;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AlterGameResponseTable1710427662589 implements MigrationInterface {
export class AlterGameResponseTable1724946964958 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE game_response DROP FOREIGN KEY FK_3a0c737a217bbd6bbd268203fb8`);
await queryRunner.query(`ALTER TABLE game_response DROP COLUMN gameId`);
Expand Down
Loading

0 comments on commit 90cf6d4

Please sign in to comment.