diff --git a/__tests__/app.test.ts b/__tests__/app.test.ts index 0fac045..9b9c926 100644 --- a/__tests__/app.test.ts +++ b/__tests__/app.test.ts @@ -224,7 +224,7 @@ describe('🧪 Express Application', () => { describe('DELETE /api/devices/delete', () => { const body = { - device_uuid: '5804f943-4aaf-432f-83d8-62028827ac57', + device_uuid: 'e062ebb6-4f14-4123-87bc-d31791756107', }; it('204: should return no content on successful deletion', () => { return request(app) @@ -300,5 +300,70 @@ describe('🧪 Express Application', () => { return request(app).post('/api/cats/update').send(data).expect(401); }); }); + + describe('GET /api/cats/leaderboard/:range', () => { + it('400: should return when passed an invalid date range', () => { + return request(app) + .get('/api/cats/leaderboard/INVALID') + .expect(400) + .then(({ body: { success, message } }) => { + expect(success).toBe(false); + expect(message).toBe("'invalid' is not a recognized range"); + }); + }); + describe('RANGE', () => { + it('200: DAILY', () => { + return request(app) + .get('/api/cats/leaderboard/DAILY') + .expect(200) + .then(({ body: { success, data, range } }) => { + expect(success).toBe(true); + expect(range).toBe('daily'); + expect(data).toBeSorted(); + expect(data.length).toBe(2); + }); + }); + it('200: WEEKLY', () => { + return request(app) + .get('/api/cats/leaderboard/WEEKLY') + .expect(200) + .then(({ body: { success, data, range } }) => { + expect(success).toBe(true); + expect(range).toBe('weekly'); + expect(data).toBeSorted(); + }); + }); + it('200: MONTHLY', () => { + return request(app) + .get('/api/cats/leaderboard/MONTHLY') + .expect(200) + .then(({ body: { success, data, range } }) => { + expect(success).toBe(true); + expect(range).toBe('monthly'); + expect(data).toBeSorted(); + }); + }); + it('200: YEARLY', () => { + return request(app) + .get('/api/cats/leaderboard/YEARLY') + .expect(200) + .then(({ body: { success, data, range } }) => { + expect(success).toBe(true); + expect(range).toBe('yearly'); + expect(data).toBeSorted(); + }); + }); + it('200: ALL_TIME', () => { + return request(app) + .get('/api/cats/leaderboard/all_time') + .expect(200) + .then(({ body: { success, data, range } }) => { + expect(success).toBe(true); + expect(range).toBe('all_time'); + expect(data).toBeSorted(); + }); + }); + }); + }); }); }); diff --git a/jest.config.js b/jest.config.js index a3b97a0..96703d5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,4 +4,5 @@ module.exports = { transform: { '^.+.tsx?$': ['ts-jest', {}], }, + setupFilesAfterEnv: ['jest-sorted'], }; diff --git a/package-lock.json b/package-lock.json index b219268..55e298d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "globals": "^15.12.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jest-sorted": "^1.0.15", "lint-staged": "^15.2.10", "nodemon": "^3.1.7", "prettier": "3.3.3", @@ -5353,6 +5354,13 @@ "node": ">=10" } }, + "node_modules/jest-sorted": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/jest-sorted/-/jest-sorted-1.0.15.tgz", + "integrity": "sha512-bb5HzfyUqv22ToD63KRACZiwHVRVVj6/bl53VOODNQSzmTwKuTM0zYI73aByYulYVkugPmgGBXUTY8YLJYotKw==", + "dev": true, + "license": "ISC" + }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", diff --git a/package.json b/package.json index 64070f7..794c170 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "globals": "^15.12.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jest-sorted": "^1.0.15", "lint-staged": "^15.2.10", "nodemon": "^3.1.7", "prettier": "3.3.3", @@ -62,4 +63,4 @@ "npm run format" ] } -} \ No newline at end of file +} diff --git a/prisma/seed.ts b/prisma/seed.ts index b66b13f..2132680 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -28,12 +28,13 @@ async function seeder() { name: 'Tiddles Collar', owner_id: user1.id, uuid: '5804f943-4aaf-432f-83d8-62028827ac57', + last_pulse_at: '2024-11-25T16:22:11.108Z', + last_location: { lon: -1.445, lat: 53.8075 }, location_history: [ - { lon: -1.445, lat: 53.8075 }, - { lon: -1.446, lat: 53.807 }, - { lon: -1.447, lat: 53.8065 }, - { lon: -1.4485, lat: 53.806 }, - { lon: -1.4495, lat: 53.8055 }, + { lon: -1.446, lat: 53.807, timestamp: '2024-11-25T16:15:11.108Z' }, + { lon: -1.447, lat: 53.8065, timestamp: '2024-11-25T16:08:11.108Z' }, + { lon: -1.4485, lat: 53.806, timestamp: '2024-11-25T16:01:11.108Z' }, + { lon: -1.4495, lat: 53.8055, timestamp: '2024-11-25T15:54:11.108Z' }, ], }, }); @@ -45,17 +46,30 @@ async function seeder() { name: 'A Collar', owner_id: user1.id, uuid: '36932d18-78a2-4ceb-b979-64a5ed441551', + last_pulse_at: '2024-11-25T16:22:11.108Z', + last_location: { lon: -1.447, lat: 53.8035 }, location_history: [ - { lon: -1.447, lat: 53.8035 }, - { lon: -1.4485, lat: 53.804 }, - { lon: -1.449, lat: 53.8045 }, - { lon: -1.45, lat: 53.805 }, - { lon: -1.451106, lat: 53.806201 }, + { lon: -1.4485, lat: 53.804, timestamp: '2024-11-25T16:15:11.108Z' }, + { lon: -1.449, lat: 53.8045, timestamp: '2024-11-25T16:08:11.108Z' }, + { lon: -1.45, lat: 53.805, timestamp: '2024-11-25T16:01:11.108Z' }, + { + lon: -1.451106, + lat: 53.806201, + timestamp: '2024-11-25T15:54:11.108Z', + }, ], }, }); - // cm3pyzthe000108jka5f7hw99 + await prisma.device.upsert({ + where: { uuid: 'e062ebb6-4f14-4123-87bc-d31791756107' }, + update: {}, + create: { + name: 'A Collar', + owner_id: user1.id, + uuid: 'e062ebb6-4f14-4123-87bc-d31791756107', + }, + }); await prisma.cat.upsert({ where: { id: 'cm3pr1rkb000008l8fs7icr9g' }, diff --git a/src/controllers/cats.ts b/src/controllers/cats.ts index 2016c58..3d72447 100644 --- a/src/controllers/cats.ts +++ b/src/controllers/cats.ts @@ -45,7 +45,7 @@ export function updateCat( cats.fetchCatsByUserID(user.id).then((usersCats) => { // ensure that the authenticated user owns the cat const catMatch = usersCats.find((cat) => cat.id === result.body.cat_id); - if (!catMatch) return next({ status: 404, message: 'Cat does not exist' }); + if (!catMatch) throw new Error('no cat found'); delete result.body.cat_id; cats.updateCat(catMatch.id, result.body).then((updated_cat) => { @@ -53,3 +53,24 @@ export function updateCat( }); }); } + +export async function getLeaderboardWithRange( + request: Request, + response: Response, + next: NextFunction +) { + const validRanges = ['daily', 'weekly', 'monthly', 'yearly', 'all_time']; + let { range } = request.params; + + range = range.toLowerCase(); + + if (!validRanges.includes(range)) + return next({ + status: 400, + message: `'${range}' is not a recognized range`, + }); + + const catsData = await cats.getAllCatsWithRange(range); + + response.status(200).json({ success: true, data: catsData, range: range }); +} diff --git a/src/models/cats.ts b/src/models/cats.ts index 04d8a5e..b98ceb0 100644 --- a/src/models/cats.ts +++ b/src/models/cats.ts @@ -1,4 +1,6 @@ import extendedClient from '../db/client'; +import Coordinate from '../type/CoordinateType'; +import coordsToScore from '../utils/coordsToScore'; export async function createCat( id: string, @@ -45,3 +47,72 @@ export async function updateCat(id: string, payload: object) { }, }); } + +export async function getAllCatsWithRange(range: string) { + const startDate = new Date(); + const endDate = new Date(); + + switch (range) { + case 'daily': + startDate.setDate(startDate.getDate() - 1); + break; + case 'weekly': + startDate.setDate(startDate.getDate() - 7); + break; + case 'monthly': + startDate.setMonth(startDate.getMonth() - 1); + break; + case 'yearly': + startDate.setFullYear(startDate.getFullYear() - 1); + break; + case 'all_time': + startDate.setTime(0); + break; + } + + const data = await extendedClient.device.findMany({ + // Join cat onto the result + include: { + cat: true, + }, + where: { + // We don't want devices that have no data, or no associated cat + NOT: [ + { + last_pulse_at: null, + }, + { + cat: null, + }, + ], + }, + }); + + // As the location history is nested as an array in the row, we'll just have to do some + // funky array methods. I wanted to use GTE and LTE, but apparently they don't work. + const sortedData = data + .map((cat) => { + // Get all location history that we care about + const historyInRange = cat.location_history.filter((item) => { + const parsedTimestamp = new Date(item.timestamp); + return parsedTimestamp > startDate && parsedTimestamp < endDate; + }); + // Convert our history into a useable format + const historyAsCoordinates: Coordinate[] = historyInRange.map((item) => [ + item.lat, + item.lon, + ]); + // Create our final coordinates array that contains current location plus all the history + const coordinates: Coordinate[] = [ + [cat.last_location.lat, cat.last_location.lon], + ...historyAsCoordinates, + ]; + // Calculate the score + const score = coordsToScore(coordinates); + // Return an object with the score K:V appended + return { ...cat, score: score }; + }) + .sort((a, b) => b.score - a.score); // Sort descending + + return sortedData; +} diff --git a/src/models/devices.ts b/src/models/devices.ts index 5cb8bdc..81beca9 100644 --- a/src/models/devices.ts +++ b/src/models/devices.ts @@ -48,6 +48,7 @@ export async function updateDevice( history.push({ lat: currentInformation.last_location.lat, lon: currentInformation.last_location.lon, + timestamp: new Date().toISOString(), }); // We have previous data. Lets push it to the history before we overwrite } diff --git a/src/routes/cats.ts b/src/routes/cats.ts index 9d6e68f..5a78454 100644 --- a/src/routes/cats.ts +++ b/src/routes/cats.ts @@ -6,5 +6,6 @@ const cats: Router = Router(); cats.post('/create', usernameAuth, controller.createCat); cats.post('/update', usernameAuth, controller.updateCat); +cats.get('/leaderboard/:range', controller.getLeaderboardWithRange); export default cats; diff --git a/src/type/CoordinateType.ts b/src/type/CoordinateType.ts new file mode 100644 index 0000000..38a3d17 --- /dev/null +++ b/src/type/CoordinateType.ts @@ -0,0 +1,3 @@ +type Coordinate = [number, number]; + +export default Coordinate; diff --git a/src/type/LocationHistoryType.ts b/src/type/LocationHistoryType.ts index 45b7131..b791dd9 100644 --- a/src/type/LocationHistoryType.ts +++ b/src/type/LocationHistoryType.ts @@ -1,3 +1,3 @@ -type LocationHistoryType = { lat: number; lon: number }[]; +type LocationHistoryType = { lat: number; lon: number; timestamp: string }[]; export default LocationHistoryType; diff --git a/src/utils/coordsToScore.ts b/src/utils/coordsToScore.ts new file mode 100644 index 0000000..ceac090 --- /dev/null +++ b/src/utils/coordsToScore.ts @@ -0,0 +1,25 @@ +/** + * @author PhilTBatt + */ +export default function coordsToScore(coordinates: [number, number][]): number { + const earthRadiusPerDegree = 111000; + let totalLength = 0; + + for (let i = 0; i < coordinates.length - 1; i++) { + const [lat1, lng1] = coordinates[i]; + const [lat2, lng2] = coordinates[i + 1]; + + const deltaLat = lat2 - lat1; + const deltaLng = lng2 - lng1; + + const deltaLatMeters = deltaLat * earthRadiusPerDegree; + const deltaLngMeters = + deltaLng * earthRadiusPerDegree * Math.cos((lat1 * Math.PI) / 180); + + totalLength += Math.sqrt( + deltaLatMeters * deltaLatMeters + deltaLngMeters * deltaLngMeters + ); + } + + return totalLength; +} diff --git a/tsconfig.json b/tsconfig.json index 4daa626..a7232ec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "es2016", "module": "commonjs", - "types": ["jest"], + "types": ["jest", "jest-sorted"], "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true,