Skip to content

Commit 07b251b

Browse files
authored
Merge pull request #126 from PFConnect/canary
Canary
2 parents c1bc360 + 57bac18 commit 07b251b

18 files changed

Lines changed: 1224 additions & 225 deletions

File tree

.github/workflows/enforce-canary.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@ name: Enforce Canary as PR Source
33
on:
44
pull_request:
55
types: [opened, reopened, synchronize]
6-
branches: [main]
6+
branches: [main, canary]
77

88
jobs:
99
check-branch:
1010
runs-on: ubuntu-latest
1111
steps:
1212
- name: Ensure PR source is canary
1313
run: |
14-
if [[ "${{ github.head_ref }}" != "canary" ]]; then
14+
if [[ "${{ github.base_ref }}" == "main" && "${{ github.head_ref }}" != "canary" ]]; then
1515
echo "PRs to main must originate from the canary branch."
1616
exit 1
1717
fi
18+
echo "PR source is valid for the target branch."

server/db/admin.ts

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ export async function getAllUsers(
310310
try {
311311
let cursor = '0';
312312
const cachedUserIds: string[] = [];
313-
313+
314314
do {
315315
const [newCursor, keys] = await redisConnection.scan(
316316
cursor,
@@ -320,11 +320,11 @@ export async function getAllUsers(
320320
1000
321321
);
322322
cursor = newCursor;
323-
323+
324324
const userIds = keys
325325
.filter(key => key.startsWith('user:') && !key.includes(':username:'))
326326
.map(key => key.replace('user:', ''));
327-
327+
328328
cachedUserIds.push(...userIds);
329329
} while (cursor !== '0');
330330

@@ -624,6 +624,164 @@ export async function syncUserSessionCounts() {
624624
}
625625
}
626626

627+
export async function getControllerRatingStats() {
628+
const cacheKey = 'admin:controller_rating_stats';
629+
630+
try {
631+
const cached = await redisConnection.get(cacheKey);
632+
if (cached) {
633+
return JSON.parse(cached);
634+
}
635+
} catch (error) {
636+
if (error instanceof Error) {
637+
console.warn(
638+
'[Redis] Failed to read cache for controller rating stats:',
639+
error.message
640+
);
641+
}
642+
}
643+
644+
try {
645+
const topRatedControllers = await mainDb
646+
.selectFrom('controller_ratings')
647+
.select([
648+
'controller_id',
649+
(eb) => eb.fn.avg<number>('rating').as('avg_rating'),
650+
(eb) => eb.fn.count<number>('id').as('rating_count'),
651+
])
652+
.groupBy('controller_id')
653+
.having((eb) => eb.fn.count<number>('id'), '>=', 3)
654+
.orderBy('avg_rating', 'desc')
655+
.limit(10)
656+
.execute();
657+
658+
const mostRatedControllers = await mainDb
659+
.selectFrom('controller_ratings')
660+
.select([
661+
'controller_id',
662+
(eb) => eb.fn.count<number>('id').as('rating_count'),
663+
(eb) => eb.fn.avg<number>('rating').as('avg_rating'),
664+
])
665+
.groupBy('controller_id')
666+
.orderBy('rating_count', 'desc')
667+
.limit(10)
668+
.execute();
669+
670+
const topRatingPilots = await mainDb
671+
.selectFrom('controller_ratings')
672+
.select([
673+
'pilot_id',
674+
(eb) => eb.fn.count<number>('id').as('rating_count'),
675+
])
676+
.groupBy('pilot_id')
677+
.orderBy('rating_count', 'desc')
678+
.limit(9)
679+
.execute();
680+
681+
const controllerIds = [
682+
...new Set([
683+
...topRatedControllers.map((c) => c.controller_id),
684+
...mostRatedControllers.map((c) => c.controller_id),
685+
]),
686+
];
687+
const pilotIds = topRatingPilots.map((p) => p.pilot_id);
688+
689+
const users = await mainDb
690+
.selectFrom('users')
691+
.select(['id', 'username', 'avatar'])
692+
.where('id', 'in', [...controllerIds, ...pilotIds])
693+
.execute();
694+
695+
const userMap = new Map(users.map((u) => [u.id, { username: u.username, avatar: u.avatar }]));
696+
697+
const result = {
698+
topRated: topRatedControllers.map((c) => ({
699+
...c,
700+
username: userMap.get(c.controller_id)?.username || 'Unknown',
701+
avatar: userMap.get(c.controller_id)?.avatar || null,
702+
})),
703+
mostRated: mostRatedControllers.map((c) => ({
704+
...c,
705+
username: userMap.get(c.controller_id)?.username || 'Unknown',
706+
avatar: userMap.get(c.controller_id)?.avatar || null,
707+
})),
708+
topPilots: topRatingPilots.map((p) => ({
709+
...p,
710+
username: userMap.get(p.pilot_id)?.username || 'Unknown',
711+
avatar: userMap.get(p.pilot_id)?.avatar || null,
712+
})),
713+
};
714+
715+
try {
716+
await redisConnection.set(cacheKey, JSON.stringify(result), 'EX', 300);
717+
} catch (error) {
718+
if (error instanceof Error) {
719+
console.warn(
720+
'[Redis] Failed to set cache for controller rating stats:',
721+
error.message
722+
);
723+
}
724+
}
725+
726+
return result;
727+
} catch (error) {
728+
console.error('Error fetching controller rating stats:', error);
729+
throw error;
730+
}
731+
}
732+
733+
export async function getControllerRatingsDailyStats(days: number = 30) {
734+
const cacheKey = `admin:controller_rating_daily_stats:${days}`;
735+
736+
try {
737+
const cached = await redisConnection.get(cacheKey);
738+
if (cached) {
739+
return JSON.parse(cached);
740+
}
741+
} catch (error) {
742+
if (error instanceof Error) {
743+
console.warn(
744+
`[Redis] Failed to read cache for controller rating daily stats (${days} days):`,
745+
error.message
746+
);
747+
}
748+
}
749+
750+
try {
751+
const dailyStats = await mainDb
752+
.selectFrom('controller_ratings')
753+
.select([
754+
sql<string>`DATE(created_at)`.as('date'),
755+
(eb) => eb.fn.count<number>('id').as('count'),
756+
(eb) => eb.fn.avg<number>('rating').as('avg_rating'),
757+
])
758+
.where(
759+
'created_at',
760+
'>=',
761+
sql<Date>`NOW() - INTERVAL '${sql.raw(days.toString())} days'`
762+
)
763+
.groupBy(sql`DATE(created_at)`)
764+
.orderBy(sql`DATE(created_at)`, 'asc')
765+
.execute();
766+
767+
try {
768+
await redisConnection.set(cacheKey, JSON.stringify(dailyStats), 'EX', 300);
769+
} catch (error) {
770+
if (error instanceof Error) {
771+
console.warn(
772+
`[Redis] Failed to set cache for controller rating daily stats (${days} days):`,
773+
error.message
774+
);
775+
}
776+
}
777+
778+
return dailyStats;
779+
} catch (error) {
780+
console.error('Error fetching daily controller rating stats:', error);
781+
throw error;
782+
}
783+
}
784+
627785
export async function invalidateAllUsersCache() {
628786
try {
629787
let cursor = '0';

server/routes/admin/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import updateModalsRouter from './updateModals.js';
1818
import flightLogsRouter from './flight-logs.js';
1919
import feedbackRouter from './feedback.js';
2020
import apiLogsRouter from './api-logs.js';
21+
import ratingsRouter from './ratings.js';
2122

2223
const router = express.Router();
2324

@@ -35,6 +36,7 @@ router.use('/update-modals', updateModalsRouter);
3536
router.use('/flight-logs', flightLogsRouter);
3637
router.use('/feedback', feedbackRouter);
3738
router.use('/api-logs', apiLogsRouter);
39+
router.use('/ratings', ratingsRouter);
3840

3941
// GET: /api/admin/statistics - Get dashboard statistics
4042
router.get('/statistics', requirePermission('admin'), async (req, res) => {

server/routes/admin/ratings.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import express from 'express';
2+
import { requirePermission } from '../../middleware/rolePermissions.js';
3+
import {
4+
getControllerRatingStats,
5+
getControllerRatingsDailyStats,
6+
} from '../../db/admin.js';
7+
8+
const router = express.Router();
9+
10+
// GET: /api/admin/ratings/stats - Get controller rating statistics
11+
router.get('/stats', requirePermission('admin'), async (req, res) => {
12+
try {
13+
const stats = await getControllerRatingStats();
14+
res.json(stats);
15+
} catch (error) {
16+
console.error('Error fetching controller rating stats:', error);
17+
res.status(500).json({ error: 'Failed to fetch rating statistics' });
18+
}
19+
});
20+
21+
// GET: /api/admin/ratings/daily - Get daily controller rating statistics
22+
router.get('/daily', requirePermission('admin'), async (req, res) => {
23+
try {
24+
const daysParam = req.query.days;
25+
const days =
26+
typeof daysParam === 'string'
27+
? parseInt(daysParam)
28+
: Array.isArray(daysParam) && typeof daysParam[0] === 'string'
29+
? parseInt(daysParam[0])
30+
: 30;
31+
32+
const dailyStats = await getControllerRatingsDailyStats(days);
33+
res.json(dailyStats);
34+
} catch (error) {
35+
console.error('Error fetching daily controller rating stats:', error);
36+
res.status(500).json({ error: 'Failed to fetch daily rating statistics' });
37+
}
38+
});
39+
40+
export default router;

0 commit comments

Comments
 (0)