From 1119fcde25c1595d01bcd76d3c5bd41e4d7a1b8e Mon Sep 17 00:00:00 2001 From: Thibault Guillou Date: Thu, 12 Dec 2024 15:47:13 +0100 Subject: [PATCH] conflict server --- server/entities/phaseHistory.ts | 25 +++++ server/entities/village.ts | 4 + server/stats/classroomStats.ts | 37 ++++++- server/stats/globalStats.ts | 31 ++++++ server/stats/queryStatsByFilters.ts | 149 ++++++++++++++++++++++++++++ server/stats/villageStats.ts | 0 6 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 server/entities/phaseHistory.ts create mode 100644 server/stats/globalStats.ts create mode 100644 server/stats/queryStatsByFilters.ts create mode 100644 server/stats/villageStats.ts diff --git a/server/entities/phaseHistory.ts b/server/entities/phaseHistory.ts new file mode 100644 index 000000000..fdd73dd9d --- /dev/null +++ b/server/entities/phaseHistory.ts @@ -0,0 +1,25 @@ +import { Column, CreateDateColumn, DeleteDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; + +import type { VillagePhase } from './village'; +import { Village } from './village'; + +@Entity() +@Index('IDX_PHASE_HISTORY', ['village', 'phase'], { unique: true }) +export class PhaseHistory { + @PrimaryGeneratedColumn() + public id: number; + + @ManyToOne(() => Village, (village) => village.phaseHistories, { onDelete: 'CASCADE' }) + village: Village; + + @Column({ + type: 'tinyint', + }) + phase: VillagePhase; + + @CreateDateColumn({ type: 'datetime' }) + public startingOn: Date; + + @DeleteDateColumn({ type: 'datetime' }) + public endingOn: Date; +} diff --git a/server/entities/village.ts b/server/entities/village.ts index 9081e9f51..9d8b8a487 100644 --- a/server/entities/village.ts +++ b/server/entities/village.ts @@ -9,6 +9,7 @@ import { Classroom } from './classroom'; import { Game } from './game'; import { GameResponse } from './gameResponse'; import { Image } from './image'; +import { PhaseHistory } from './phaseHistory'; import { User } from './user'; export { VillagePhase }; @@ -39,6 +40,9 @@ export class Village implements VillageInterface { }) public activePhase: number; + @OneToMany(() => PhaseHistory, (phaseHistory) => phaseHistory.village, { eager: true }) + public phaseHistories: PhaseHistory[]; + @OneToOne(() => Activity, { onDelete: 'SET NULL' }) @JoinColumn({ name: 'anthemId' }) public anthem: Activity | null; diff --git a/server/stats/classroomStats.ts b/server/stats/classroomStats.ts index 10d8c8215..400bd6286 100644 --- a/server/stats/classroomStats.ts +++ b/server/stats/classroomStats.ts @@ -1,8 +1,8 @@ import { UserType } from '../../types/user.type'; -import type { Activity } from '../entities/activity'; import { Classroom } from '../entities/classroom'; -import { Video } from '../entities/video'; +import type { VillagePhase } from '../entities/village'; import { AppDataSource } from '../utils/data-source'; +import { generateEmptyFilterParams, getChildrenCodesCount, getConnectedFamiliesCount, getFamiliesWithoutAccount } from './queryStatsByFilters'; const classroomRepository = AppDataSource.getRepository(Classroom); @@ -135,3 +135,36 @@ export const normalizeForCountry = (inputData: any) => { return result; }; + +export const getChildrenCodesCountForClassroom = async (classroomId: number, phase: VillagePhase) => { + const classroom = await classroomRepository + .createQueryBuilder('classroom') + .innerJoin('classroom.village', 'village') + .where('classroom.id = :classroomId', { classroomId }) + .getOne(); + + const villageId = classroom?.villageId; + + if (!classroomId || !villageId) return 0; + let filterParams = generateEmptyFilterParams(); + filterParams = { ...filterParams, villageId, classroomId, phase }; + const whereClause = { clause: 'classroom.id = :classroomId', value: { classroomId } }; + return await getChildrenCodesCount(filterParams, whereClause); +}; + +export const getConnectedFamiliesCountForClassroom = async (classroomId: number, phase: VillagePhase) => { + const classroom = await classroomRepository + .createQueryBuilder('classroom') + .innerJoin('classroom.village', 'village') + .where('classroom.id = :classroomId', { classroomId }) + .getOne(); + + const villageId = classroom?.villageId; + let filterParams = generateEmptyFilterParams(); + filterParams = { ...filterParams, villageId, classroomId, phase }; + return await getConnectedFamiliesCount(filterParams); +}; + +export const getFamiliesWithoutAccountForClassroom = async (classroomId: number) => { + return getFamiliesWithoutAccount('classroom.id = :classroomId', { classroomId }); +}; diff --git a/server/stats/globalStats.ts b/server/stats/globalStats.ts new file mode 100644 index 000000000..8732a231a --- /dev/null +++ b/server/stats/globalStats.ts @@ -0,0 +1,31 @@ +import { + generateEmptyFilterParams, + getChildrenCodesCount, + getConnectedFamiliesCount, + getFamiliesWithoutAccount, + getFamilyAccountsCount, + getFloatingAccounts, +} from './queryStatsByFilters'; + +export const getFamiliesWithoutAccountForGlobal = async () => { + return getFamiliesWithoutAccount(); +}; + +export const getConnectedFamiliesCountForGlobal = async () => { + const filterParams = generateEmptyFilterParams(); + return getConnectedFamiliesCount(filterParams); +}; + +export const getChildrenCodesCountForGlobal = async () => { + const filterParams = generateEmptyFilterParams(); + return await getChildrenCodesCount(filterParams); +}; +export const getFloatingAccountsForGlobal = async () => { + const filterParams = generateEmptyFilterParams(); + return await getFloatingAccounts(filterParams); +}; + +export const getFamilyAccountsCountForGlobal = async () => { + const filterParams = generateEmptyFilterParams(); + return await getFamilyAccountsCount(filterParams); +}; diff --git a/server/stats/queryStatsByFilters.ts b/server/stats/queryStatsByFilters.ts new file mode 100644 index 000000000..8e97d32b9 --- /dev/null +++ b/server/stats/queryStatsByFilters.ts @@ -0,0 +1,149 @@ +import type { StatsFilterParams } from '../../types/statistics.type'; +import { PhaseHistory } from '../entities/phaseHistory'; +import { Student } from '../entities/student'; +import { User } from '../entities/user'; +import { VillagePhase, Village } from '../entities/village'; +import { AppDataSource } from '../utils/data-source'; + +const studentRepository = AppDataSource.getRepository(Student); +const villageRepository = AppDataSource.getRepository(Village); +const userRepository = AppDataSource.getRepository(User); +const phaseHistoryRepository = AppDataSource.getRepository(PhaseHistory); + +export const getFamiliesWithoutAccount = async (condition?: string, conditionValue?: object) => { + const query = studentRepository + .createQueryBuilder('student') + .innerJoin('student.classroom', 'classroom') + .innerJoin('classroom.user', 'user') + .innerJoin('user.village', 'village') + .where('student.numLinkedAccount < 1'); + + if (condition && conditionValue) { + query.andWhere(condition, conditionValue); + } + + query.select([ + 'classroom.name AS classroom_name', + 'classroom.countryCode as classroom_country', + 'student.firstname AS student_firstname', + 'student.lastname AS student_lastname', + 'student.id AS student_id', + 'student.createdAt as student_creation_date', + 'village.name AS village_name', + ]); + + return query.getRawMany(); +}; + +export const getConnectedFamiliesCount = async (filterParams: StatsFilterParams) => { + const { villageId, classroomId, phase } = filterParams; + const query = studentRepository + .createQueryBuilder('student') + .innerJoin('classroom', 'classroom', 'classroom.id = student.classroomId') + .andWhere('student.numLinkedAccount >= 1'); + + if (classroomId) { + query.andWhere('classroom.id = :classroomId', { classroomId }); + } + + if (villageId) { + query.andWhere('classroom.villageId = :villageId', { villageId }); + + if (phaseWasSelected(phase)) { + const phaseValue = phase as number; + const { debut, end } = await getPhasePeriod(villageId, phaseValue); + query.andWhere('student.createdAt >= :debut', { debut }); + if (phaseValue != (await villageRepository.findOne({ where: { id: villageId } }))?.activePhase) { + query.andWhere('student.createdAt <= :end', { end }); + } + } + } + + return await query.getCount(); +}; + +export const getFloatingAccounts = async (filterParams: StatsFilterParams) => { + const { villageId } = filterParams; + const query = userRepository.createQueryBuilder('user').where('user.hasStudentLinked = 0').andWhere('user.type = 4'); + + if (villageId) query.andWhere('user.villageId = :villageId', { villageId }); + + query.select(['user.id', 'user.firstname', 'user.lastname', 'user.language', 'user.email', 'user.createdAt']); + const floatingAccounts = await query.getMany(); + return floatingAccounts; +}; + +export const getFamilyAccountsCount = async (filterParams: { villageId: number | undefined; phase: VillagePhase | undefined }) => { + const { villageId, phase } = filterParams; + const village = await villageRepository.findOne({ where: { id: villageId } }); + const query = userRepository + .createQueryBuilder('user') + .innerJoin('user.village', 'village') + .innerJoin('classroom', 'classroom', 'classroom.villageId = village.id') + .innerJoin('student', 'student', 'student.classroomId = classroom.id') + .where('user.type = 3'); + + if (villageId) { + query.andWhere('classroom.villageId = :villageId', { villageId }); + if (phaseWasSelected(phase)) { + const phaseValue = phase as number; + const { debut, end } = await getPhasePeriod(villageId, phaseValue); + query.andWhere('user.createdAt >= :debut', { debut }); + if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); + } + } + + query.groupBy('user.id'); + const familyAccountsCount = await query.getCount(); + return familyAccountsCount; +}; + +export const generateEmptyFilterParams = (): StatsFilterParams => { + const filterParams: { [K in keyof StatsFilterParams]: StatsFilterParams[K] } = { + villageId: undefined, + classroomId: undefined, + phase: undefined, + }; + + return filterParams; +}; +export type WhereClause = { + clause: string; + value: object; +}; +export const getChildrenCodesCount = async (filterParams: StatsFilterParams, whereClause?: WhereClause) => { + const { villageId, phase } = filterParams; + const village = await villageRepository.findOne({ where: { id: villageId } }); + const query = studentRepository.createQueryBuilder('student').innerJoin('student.classroom', 'classroom').innerJoin('classroom.village', 'village'); + if (whereClause) query.where(whereClause.clause, whereClause.value); + + if (phaseWasSelected(phase) && villageId) { + const phaseValue = phase as number; + const { debut, end } = await getPhasePeriod(villageId, phaseValue); + query.andWhere('student.createdAt >= :debut', { debut }); + if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); + } + + return await query.getCount(); +}; + +export const getPhasePeriod = async (villageId: number, phase: number): Promise<{ debut: Date | undefined; end: Date | undefined }> => { + // Getting the debut and end dates for the given phase + const query = phaseHistoryRepository + .createQueryBuilder('phaseHistory') + .withDeleted() + .where('phaseHistory.villageId = :villageId', { villageId }) + .andWhere('phaseHistory.phase = :phase', { phase }); + query.select(['phaseHistory.startingOn', 'phaseHistory.endingOn']); + const result = await query.getOne(); + const debut = result?.startingOn; + const end = result?.endingOn; + return { + debut, + end, + }; +}; + +export const phaseWasSelected = (phase: number | undefined): boolean => { + return phase !== undefined && Object.values(VillagePhase).includes(+phase); +}; diff --git a/server/stats/villageStats.ts b/server/stats/villageStats.ts new file mode 100644 index 000000000..e69de29bb