Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 2024-43 #5939

Closed
wants to merge 9 commits into from
1 change: 1 addition & 0 deletions packages/auth/src/Authenticator.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export class Authenticator {
user_id: user.id,
platform,
app_version: appVersion,
last_login: new Date(),
},
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ import { testAuthenticateRefreshToken } from './testAuthenticateRefreshToken';
jest.mock('rand-token');
randomToken.generate.mockReturnValue(refreshToken);

beforeAll(() => {
jest.useFakeTimers('modern');
jest.setSystemTime(new Date(2020, 3, 1));
});

afterAll(() => {
jest.useRealTimers();
});

describe('Authenticator', () => {
describe('authenticatePassword', testAuthenticatePassword);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export const testAuthenticatePassword = () => {
user_id: verifiedUser.id,
app_version: meditrakDeviceDetails.appVersion,
platform: meditrakDeviceDetails.platform,
last_login: new Date(),
},
);

Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/userAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export const getAuthorizationObject = async ({
email: user.email,
profileImage: user.profile_image,
verifiedEmail: user.verified_email,
preferences: user.preferences,
accessPolicy,
};
if (permissionGroups) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export async function updateCountryEntities(
countryCode,
pushToDhis,
);

await transactingModels.entity.findOrCreate(
{ code: countryCode },
{
Expand All @@ -103,6 +104,7 @@ export async function updateCountryEntities(
},
);
const codes = []; // An array to hold all facility codes, allowing duplicate checking

for (let i = 0; i < entityObjects.length; i++) {
const entityObject = entityObjects[i];
const { entity_type: entityType } = entityObject;
Expand Down Expand Up @@ -192,7 +194,27 @@ export async function updateCountryEntities(
geojson.type === 'Polygon'
? { type: 'MultiPolygon', coordinates: [geojson.coordinates] }
: geojson;
await transactingModels.entity.updateRegionCoordinates(code, translatedGeojson);

try {
await transactingModels.entity.updateRegionCoordinates(code, translatedGeojson);
} catch (error) {
if (error.message.includes('payload string too long')) {
const largeGeoEntities = entityObjects.filter(entityObject => {
if (!entityObject?.geojson) return false;
const geoJsonString = JSON.stringify(entityObject.geojson);
// If the geo json is too large, we will hit the max payload size limit.
// Hard postgres max is 8000 characters, but we need to account for other data in the query payload
const maxGeoJsonPayload = 5200;
if (geoJsonString.length > maxGeoJsonPayload) {
return true;
}
});
const text = largeGeoEntities.map(entity => entity.code).join(', ');
error.message = `Error updating region coordinates for entities: ${text} ${error.message}`;
}

throw error;
}
}
}
return country;
Expand Down
9 changes: 8 additions & 1 deletion packages/database/src/TupaiaDatabase.js
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,8 @@ function addWhereClause(connection, baseQuery, where) {
return querySoFar; // Ignore undefined criteria
}
if (value === null) {
return querySoFar.whereNull(key);
const columnKey = getColSelector(connection, key);
return querySoFar.whereNull(columnKey);
}
const {
comparisonType = 'where',
Expand Down Expand Up @@ -748,5 +749,11 @@ function getColSelector(connection, inputColStr) {
return connection.raw(inputColStr);
}

const asGeoJsonPattern = /^ST_AsGeoJSON\((.+)\)$/;
if (asGeoJsonPattern.test(inputColStr)) {
const [, argsString] = inputColStr.match(asGeoJsonPattern);
return connection.raw(`ST_AsGeoJSON(${argsString})`);
}

return inputColStr;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/
import { getLeaderboard } from '../../modelClasses/SurveyResponse';

const USERS_EXCLUDED_FROM_LEADER_BOARD = [
"'edmofro@gmail.com'",
"'kahlinda.mahoney@gmail.com'",
"'lparish1980@gmail.com'",
"'sus.lake@gmail.com'",
"'michaelnunan@hotmail.com'",
"'vanbeekandrew@gmail.com'",
"'gerardckelly@gmail.com'",
"'geoffreyfisher@hotmail.com'",
"'unicef.laos.edu@gmail.com'",
];
const SYSTEM_USERS = ["'tamanu-server@tupaia.org'", "'public@tupaia.org'", "'josh@sussol.net'"];

const whitespace = /\s/g;
const expectToBe = (expected, received) => {
expect(received.replace(whitespace, '')).toBe(expected.replace(whitespace, ''));
};

describe('getLeaderboard()', () => {
it('should filter out internal users on standard projects', async () => {
const expectedLeaderboard = `SELECT r.user_id, user_account.first_name, user_account.last_name, r.coconuts, r.pigs
FROM (
SELECT user_id, FLOOR(COUNT(*)) as coconuts, FLOOR(COUNT(*) / 100) as pigs
FROM survey_response
JOIN survey on survey.id=survey_id
WHERE survey.project_id = ?
GROUP BY user_id
) r
JOIN user_account on user_account.id = r.user_id
WHERE email NOT IN (${[...SYSTEM_USERS, ...USERS_EXCLUDED_FROM_LEADER_BOARD].join(', ')})
AND email NOT LIKE '%@beyondessential.com.au' AND email NOT LIKE '%@bes.au'
ORDER BY coconuts DESC
LIMIT ?;`;

expectToBe(getLeaderboard('5dfc6eaf61f76a497716cddf'), expectedLeaderboard);
});

it('should not filter out internal users on internal projects', async () => {
const INTERNAL_PROJECT_IDS = [
'6684ac9d0f018e110b000a00', // bes_asset_demo
'66a03660718c54751609eeed', // bes_asset_tracker
'6704622a45a4fc4941071605', // bes_reporting
];
const expectedLeaderboard = `SELECT r.user_id, user_account.first_name, user_account.last_name, r.coconuts, r.pigs
FROM (
SELECT user_id, FLOOR(COUNT(*)) as coconuts, FLOOR(COUNT(*) / 100) as pigs
FROM survey_response
JOIN survey on survey.id=survey_id
WHERE survey.project_id = ?
GROUP BY user_id
) r
JOIN user_account on user_account.id = r.user_id
WHERE email NOT IN (${SYSTEM_USERS.join(', ')})
ORDER BY coconuts DESC
LIMIT ?;`;

INTERNAL_PROJECT_IDS.forEach(projectId => {
expectToBe(getLeaderboard(projectId), expectedLeaderboard);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use strict';

var dbm;
var type;
var seed;

/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function (options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
};

exports.up = function (db) {
return db.runSql(`
ALTER TABLE meditrak_device
ADD COLUMN last_login TIMESTAMP;
`);
};

exports.down = function (db) {
return db.runSql(`
ALTER TABLE meditrak_device DROP COLUMN last_login;
`);
};

exports._meta = {
version: 1,
};
54 changes: 35 additions & 19 deletions packages/database/src/modelClasses/SurveyResponse.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,44 @@ const USERS_EXCLUDED_FROM_LEADER_BOARD = [
"'vanbeekandrew@gmail.com'", // Andrew
"'gerardckelly@gmail.com'", // Gerry K
"'geoffreyfisher@hotmail.com'", // Geoff F
"'josh@sussol.net'", // mSupply API Client
"'unicef.laos.edu@gmail.com'", // Laos Schools Data Collector
];
const SYSTEM_USERS = [
"'tamanu-server@tupaia.org'", // Tamanu Server
"'public@tupaia.org'", // Public User
"'josh@sussol.net'", // mSupply API Client
];
const INTERNAL_EMAIL = ['@beyondessential.com.au', '@bes.au'];
const INTERNAL_PROJECT_IDS = [
'6684ac9d0f018e110b000a00', // bes_asset_demo
'66a03660718c54751609eeed', // bes_asset_tracker
'6704622a45a4fc4941071605', // bes_reporting
];

export function getLeaderboard(projectId = '') {
const isInternalProject = projectId && INTERNAL_PROJECT_IDS.includes(projectId);

const besUsersFilter = `AND ${INTERNAL_EMAIL.map(email => `email NOT LIKE '%${email}'`).join(' AND ')}`;
const excludedUserAccountList = isInternalProject
? SYSTEM_USERS
: [...SYSTEM_USERS, ...USERS_EXCLUDED_FROM_LEADER_BOARD];

// FLOOR to force result to be returned as int, not string
return `SELECT r.user_id, user_account.first_name, user_account.last_name, r.coconuts, r.pigs
FROM (
SELECT user_id, FLOOR(COUNT(*)) as coconuts, FLOOR(COUNT(*) / 100) as pigs
FROM survey_response
JOIN survey on survey.id=survey_id
${projectId ? 'WHERE survey.project_id = ?' : ''}
GROUP BY user_id
) r
JOIN user_account on user_account.id = r.user_id
WHERE email NOT IN (${excludedUserAccountList.join(',')})
${!isInternalProject ? besUsersFilter : ''}
ORDER BY coconuts DESC
LIMIT ?;
`;
}

export class SurveyResponseRecord extends DatabaseRecord {
static databaseRecord = RECORDS.SURVEY_RESPONSE;
Expand All @@ -38,23 +70,7 @@ export class SurveyResponseModel extends MaterializedViewLogDatabaseModel {

async getLeaderboard(projectId = '', rowCount = 10) {
const bindings = projectId ? [projectId, rowCount] : [rowCount];
return this.database.executeSql(
`SELECT r.user_id, user_account.first_name, user_account.last_name, r.coconuts, r.pigs
FROM (
SELECT user_id, FLOOR(COUNT(*)) as coconuts, FLOOR(COUNT(*) / 100) as pigs
-- ^~~~~~~~~~~~~~~ FLOOR to force result to be returned as int, not string
FROM survey_response
JOIN survey on survey.id=survey_id
${projectId ? 'WHERE survey.project_id = ?' : ''}
GROUP BY user_id
) r
JOIN user_account on user_account.id = r.user_id
WHERE ${INTERNAL_EMAIL.map(email => `email NOT LIKE '%${email}'`).join(' AND ')}
AND email NOT IN (${USERS_EXCLUDED_FROM_LEADER_BOARD.join(',')})
ORDER BY coconuts DESC
LIMIT ?;
`,
bindings,
);
const query = getLeaderboard(projectId);
return this.database.executeSql(query, bindings);
}
}
26 changes: 24 additions & 2 deletions packages/datatrak-web/src/api/queries/useProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ import { useQuery } from '@tanstack/react-query';
import { DatatrakWebProjectsRequest } from '@tupaia/types';
import { get } from '../api';

export const useProjects = () => {
return useQuery(['projects'], (): Promise<DatatrakWebProjectsRequest.ResBody> => get('projects'));
export const useProjects = (sortByAccess = true) => {
const { data, ...query } = useQuery(
['projects'],
(): Promise<DatatrakWebProjectsRequest.ResBody> => get('projects'),
);

if (data && sortByAccess) {
data.sort((a, b) => {
// Sort by hasAccess = true first
if (a.hasAccess !== b.hasAccess) {
return a.hasAccess ? -1 : 1;
}

// Sort by hasPendingAccess = true second
if (a.hasPendingAccess !== b.hasPendingAccess) {
return a.hasPendingAccess ? -1 : 1;
}

// Otherwise, sort alphabetically by name
return a.name.localeCompare(b.name);
});
}

return { ...query, data };
};
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const ChangeProjectButton = ({ className }: { className?: string }) => {
<ProjectButton onClick={openProjectModal} tooltip="Change project">
{projectName ?? 'Select project'}
</ProjectButton>
{projectModalIsOpen && <ProjectSelectModal onClose={closeProjectModal} />}
{projectModalIsOpen && <ProjectSelectModal onBack={closeProjectModal} />}
</Container>
);
};
7 changes: 0 additions & 7 deletions packages/datatrak-web/src/components/SelectList/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/datatrak-web/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

export { PageContainer } from './PageContainer';
export * from './Icons';
export * from './SelectList';
export { Autocomplete, QuestionAutocomplete } from './Autocomplete';
export { Button } from './Button';
export { ButtonLink } from './ButtonLink';
Expand Down
20 changes: 17 additions & 3 deletions packages/datatrak-web/src/features/EntitySelector/ResultsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
* Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd
*/

import React from 'react';
import React, { ReactNode } from 'react';
import styled from 'styled-components';
import { Typography } from '@material-ui/core';
import { FormLabelProps, Typography } from '@material-ui/core';
import RoomIcon from '@material-ui/icons/Room';
import { DatatrakWebEntityDescendantsRequest } from '@tupaia/types';
import { ListItemType, SelectList } from '../../components';
import { SelectList } from '@tupaia/ui-components';

const DARK_BLUE = '#004975';

Expand Down Expand Up @@ -43,6 +43,20 @@ export const ResultItem = ({ name, parentName }) => {
);
};

type ListItemType = Record<string, unknown> & {
children?: ListItemType[];
content: string | ReactNode;
value: string;
selected?: boolean;
icon?: ReactNode;
tooltip?: string;
button?: boolean;
disabled?: boolean;
labelProps?: FormLabelProps & {
component?: React.ElementType;
};
};

type SearchResults = DatatrakWebEntityDescendantsRequest.ResBody;
interface ResultsListProps {
value: string;
Expand Down
Loading