Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/enforce-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ name: Enforce Canary as PR Source
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [main]
branches: [main, canary]

jobs:
check-branch:
runs-on: ubuntu-latest
steps:
- name: Ensure PR source is canary
run: |
if [[ "${{ github.head_ref }}" != "canary" ]]; then
if [[ "${{ github.base_ref }}" == "main" && "${{ github.head_ref }}" != "canary" ]]; then
echo "PRs to main must originate from the canary branch."
exit 1
fi
echo "PR source is valid for the target branch."
164 changes: 161 additions & 3 deletions server/db/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export async function getAllUsers(
try {
let cursor = '0';
const cachedUserIds: string[] = [];

do {
const [newCursor, keys] = await redisConnection.scan(
cursor,
Expand All @@ -320,11 +320,11 @@ export async function getAllUsers(
1000
);
cursor = newCursor;

const userIds = keys
.filter(key => key.startsWith('user:') && !key.includes(':username:'))
.map(key => key.replace('user:', ''));

cachedUserIds.push(...userIds);
} while (cursor !== '0');

Expand Down Expand Up @@ -624,6 +624,164 @@ export async function syncUserSessionCounts() {
}
}

export async function getControllerRatingStats() {
const cacheKey = 'admin:controller_rating_stats';

try {
const cached = await redisConnection.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (error) {
if (error instanceof Error) {
console.warn(
'[Redis] Failed to read cache for controller rating stats:',
error.message
);
}
}

try {
const topRatedControllers = await mainDb
.selectFrom('controller_ratings')
.select([
'controller_id',
(eb) => eb.fn.avg<number>('rating').as('avg_rating'),
(eb) => eb.fn.count<number>('id').as('rating_count'),
])
.groupBy('controller_id')
.having((eb) => eb.fn.count<number>('id'), '>=', 3)
.orderBy('avg_rating', 'desc')
.limit(10)
.execute();

const mostRatedControllers = await mainDb
.selectFrom('controller_ratings')
.select([
'controller_id',
(eb) => eb.fn.count<number>('id').as('rating_count'),
(eb) => eb.fn.avg<number>('rating').as('avg_rating'),
])
.groupBy('controller_id')
.orderBy('rating_count', 'desc')
.limit(10)
.execute();

const topRatingPilots = await mainDb
.selectFrom('controller_ratings')
.select([
'pilot_id',
(eb) => eb.fn.count<number>('id').as('rating_count'),
])
.groupBy('pilot_id')
.orderBy('rating_count', 'desc')
.limit(9)
.execute();

const controllerIds = [
...new Set([
...topRatedControllers.map((c) => c.controller_id),
...mostRatedControllers.map((c) => c.controller_id),
]),
];
const pilotIds = topRatingPilots.map((p) => p.pilot_id);

const users = await mainDb
.selectFrom('users')
.select(['id', 'username', 'avatar'])
.where('id', 'in', [...controllerIds, ...pilotIds])
.execute();

const userMap = new Map(users.map((u) => [u.id, { username: u.username, avatar: u.avatar }]));

const result = {
topRated: topRatedControllers.map((c) => ({
...c,
username: userMap.get(c.controller_id)?.username || 'Unknown',
avatar: userMap.get(c.controller_id)?.avatar || null,
})),
mostRated: mostRatedControllers.map((c) => ({
...c,
username: userMap.get(c.controller_id)?.username || 'Unknown',
avatar: userMap.get(c.controller_id)?.avatar || null,
})),
topPilots: topRatingPilots.map((p) => ({
...p,
username: userMap.get(p.pilot_id)?.username || 'Unknown',
avatar: userMap.get(p.pilot_id)?.avatar || null,
})),
};

try {
await redisConnection.set(cacheKey, JSON.stringify(result), 'EX', 300);
} catch (error) {
if (error instanceof Error) {
console.warn(
'[Redis] Failed to set cache for controller rating stats:',
error.message
);
}
}

return result;
} catch (error) {
console.error('Error fetching controller rating stats:', error);
throw error;
}
}

export async function getControllerRatingsDailyStats(days: number = 30) {
const cacheKey = `admin:controller_rating_daily_stats:${days}`;

try {
const cached = await redisConnection.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (error) {
if (error instanceof Error) {
console.warn(
`[Redis] Failed to read cache for controller rating daily stats (${days} days):`,
error.message
);
}
}

try {
const dailyStats = await mainDb
.selectFrom('controller_ratings')
.select([
sql<string>`DATE(created_at)`.as('date'),
(eb) => eb.fn.count<number>('id').as('count'),
(eb) => eb.fn.avg<number>('rating').as('avg_rating'),
])
.where(
'created_at',
'>=',
sql<Date>`NOW() - INTERVAL '${sql.raw(days.toString())} days'`
)
.groupBy(sql`DATE(created_at)`)
.orderBy(sql`DATE(created_at)`, 'asc')
.execute();

try {
await redisConnection.set(cacheKey, JSON.stringify(dailyStats), 'EX', 300);
} catch (error) {
if (error instanceof Error) {
console.warn(
`[Redis] Failed to set cache for controller rating daily stats (${days} days):`,
error.message
);
}
}

return dailyStats;
} catch (error) {
console.error('Error fetching daily controller rating stats:', error);
throw error;
}
}

export async function invalidateAllUsersCache() {
try {
let cursor = '0';
Expand Down
2 changes: 2 additions & 0 deletions server/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import updateModalsRouter from './updateModals.js';
import flightLogsRouter from './flight-logs.js';
import feedbackRouter from './feedback.js';
import apiLogsRouter from './api-logs.js';
import ratingsRouter from './ratings.js';

const router = express.Router();

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

// GET: /api/admin/statistics - Get dashboard statistics
router.get('/statistics', requirePermission('admin'), async (req, res) => {
Expand Down
40 changes: 40 additions & 0 deletions server/routes/admin/ratings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import express from 'express';
import { requirePermission } from '../../middleware/rolePermissions.js';
import {
getControllerRatingStats,
getControllerRatingsDailyStats,
} from '../../db/admin.js';

const router = express.Router();

// GET: /api/admin/ratings/stats - Get controller rating statistics
router.get('/stats', requirePermission('admin'), async (req, res) => {
try {
const stats = await getControllerRatingStats();
res.json(stats);
} catch (error) {
console.error('Error fetching controller rating stats:', error);
res.status(500).json({ error: 'Failed to fetch rating statistics' });
}
});

// GET: /api/admin/ratings/daily - Get daily controller rating statistics
router.get('/daily', requirePermission('admin'), async (req, res) => {
try {
const daysParam = req.query.days;
const days =
typeof daysParam === 'string'
? parseInt(daysParam)
: Array.isArray(daysParam) && typeof daysParam[0] === 'string'
? parseInt(daysParam[0])
: 30;

const dailyStats = await getControllerRatingsDailyStats(days);
res.json(dailyStats);
} catch (error) {
console.error('Error fetching daily controller rating stats:', error);
res.status(500).json({ error: 'Failed to fetch daily rating statistics' });
}
});

export default router;
Loading
Loading