From d3c545776f5a4e0080749b312762e02ce89bb269 Mon Sep 17 00:00:00 2001 From: Thijs Date: Sat, 16 May 2026 21:53:59 +0200 Subject: [PATCH] feat: distinguish transcender (advanced-eligible) students on the map Adds a visual distinction so common-core students and transcenders are immediately recognisable on the clustermap. Backend (src/server/intra42.ts, src/pages/api/active.ts) - New minimal 42 Intra client using the client_credentials flow. - It resolves the id of the configurable transcender title (default "Transcender of %login") once and caches it for a day. - It then fetches every user holding that title (paginated, page size 100) and caches the resulting login set for 1 hour. Failures cache an empty set for 60s so a flaky API does not get hammered. - /api/active enriches each session with isTranscender: true when the session login is in that set. If INTRA_42_UID / INTRA_42_SECRET are missing the client is considered not configured and every session silently keeps isTranscender false; the map renders exactly as before. Frontend (src/elm/clustermap/Clustermap.elm, public/css/clustermap.css) - Session record gains `isTranscender : Bool` with a Field.attempt decoder, so deployments that have not yet enriched the data behave identically to the old code path. - New .round-img.session-transcender rule paints a gold ring with a subtle glow. .session-exam / .session-dead still override (so an exam session always wins, matching existing sessionType priority). - --exam-active-color changed from yellow to red. The previous yellow was visually close to the new gold transcender ring; red is clearly distinct and still unused elsewhere (dead hosts render as a dedicated SVG icon, not a coloured border). Config (.env-example) - Adds INTRA_42_UID, INTRA_42_SECRET and the optional TRANSCENDER_TITLE_NAME override, with comments explaining each. --- .env-example | 10 + public/css/clustermap.css | 12 +- public/js/interactive_maps.js | 356 +++++++++++++++--------------- src/elm/clustermap/Clustermap.elm | 48 ++-- src/pages/api/active.ts | 15 ++ src/server/intra42.ts | 144 ++++++++++++ 6 files changed, 391 insertions(+), 194 deletions(-) create mode 100644 src/server/intra42.ts diff --git a/.env-example b/.env-example index 531d5b5..f12abfd 100644 --- a/.env-example +++ b/.env-example @@ -3,3 +3,13 @@ # Prisma DATABASE_URL= IMAGE_SECRET= + +# 42 Intra API — used to detect students who hold the "Transcender" title so +# they can be visually distinguished from common-core students on the map. +# Create an app at https://profile.intra.42.fr/oauth/applications (scope: public). +# If left blank the feature is silently disabled and every student renders +# with the common-core ring colour. +INTRA_42_UID= +INTRA_42_SECRET= +# Override only if Codam renames the title on Intra. +TRANSCENDER_TITLE_NAME=Transcender of %login diff --git a/public/css/clustermap.css b/public/css/clustermap.css index ec5ea24..09a317a 100644 --- a/public/css/clustermap.css +++ b/public/css/clustermap.css @@ -7,7 +7,7 @@ --danger-color: #fc3b3e; --drag-icon-color: #007bff; --active-color: rgba(48, 205, 49, 0.76); - --exam-active-color: rgba(206, 225, 36, 0.76); + --exam-active-color: rgba(252, 59, 62, 0.85); --exam-inactive-color: #ff831e; --popover-border: rgba(255, 255, 255, 0.2); } @@ -19,7 +19,7 @@ --foreground-color: #121212; --accent-color: #d644d1; --active-color: rgba(48, 205, 49, 0.76); - --exam-active-color: rgba(206, 225, 36, 0.76); + --exam-active-color: rgba(252, 59, 62, 0.85); --popover-border: rgba(0,0,0,.2); } } @@ -84,6 +84,14 @@ body { transform: scale(1.5); } +/* Transcenders (advanced-eligible students) get a gold ring. + Placed BEFORE .session-exam / .session-dead so those still override + for exam/dead sessions. */ +.round-img.session-transcender { + border-color: var(--transcender-color, #d4af37); + box-shadow: 0 0 6px var(--transcender-color, #d4af37); +} + .round-img.session-exam { border-color: var(--exam-active-color); } diff --git a/public/js/interactive_maps.js b/public/js/interactive_maps.js index ce3e1c5..6c32cfa 100644 --- a/public/js/interactive_maps.js +++ b/public/js/interactive_maps.js @@ -519,11 +519,11 @@ function _Debug_crash_UNUSED(identifier, fact1, fact2, fact3, fact4) function _Debug_regionToString(region) { - if (region.c4.aX === region.dr.aX) + if (region.c5.aX === region.ds.aX) { - return 'on line ' + region.c4.aX; + return 'on line ' + region.c5.aX; } - return 'on lines ' + region.c4.aX + ' through ' + region.dr.aX; + return 'on lines ' + region.c5.aX + ' through ' + region.ds.aX; } @@ -1857,9 +1857,9 @@ var _Platform_worker = F4(function(impl, flagDecoder, debugMetadata, args) return _Platform_initialize( flagDecoder, args, - impl.fk, - impl.gh, - impl.fZ, + impl.fl, + impl.gi, + impl.f_, function() { return function() {} } ); }); @@ -2705,8 +2705,8 @@ var _VirtualDom_mapEventRecord = F2(function(func, record) { return { W: func(record.W), - c6: record.c6, - c_: record.c_ + c7: record.c7, + c$: record.c$ } }); @@ -2975,10 +2975,10 @@ function _VirtualDom_makeCallback(eventNode, initialHandler) var value = result.a; var message = !tag ? value : tag < 3 ? value.a : value.W; - var stopPropagation = tag == 1 ? value.b : tag == 3 && value.c6; + var stopPropagation = tag == 1 ? value.b : tag == 3 && value.c7; var currentEventNode = ( stopPropagation && event.stopPropagation(), - (tag == 2 ? value.b : tag == 3 && value.c_) && event.preventDefault(), + (tag == 2 ? value.b : tag == 3 && value.c$) && event.preventDefault(), eventNode ); var tagger; @@ -3928,11 +3928,11 @@ var _Browser_element = _Debugger_element || F4(function(impl, flagDecoder, debug return _Platform_initialize( flagDecoder, args, - impl.fk, - impl.gh, - impl.fZ, + impl.fl, + impl.gi, + impl.f_, function(sendToApp, initialModel) { - var view = impl.gi; + var view = impl.gj; /**/ var domNode = args['node']; //*/ @@ -3964,12 +3964,12 @@ var _Browser_document = _Debugger_document || F4(function(impl, flagDecoder, deb return _Platform_initialize( flagDecoder, args, - impl.fk, - impl.gh, - impl.fZ, + impl.fl, + impl.gi, + impl.f_, function(sendToApp, initialModel) { - var divertHrefToApp = impl.c3 && impl.c3(sendToApp) - var view = impl.gi; + var divertHrefToApp = impl.c4 && impl.c4(sendToApp) + var view = impl.gj; var title = _VirtualDom_doc.title; var bodyNode = _VirtualDom_doc.body; var currNode = _VirtualDom_virtualize(bodyNode); @@ -3977,12 +3977,12 @@ var _Browser_document = _Debugger_document || F4(function(impl, flagDecoder, deb { _VirtualDom_divertHrefToApp = divertHrefToApp; var doc = view(model); - var nextNode = _VirtualDom_node('body')(_List_Nil)(doc.eN); + var nextNode = _VirtualDom_node('body')(_List_Nil)(doc.eO); var patches = _VirtualDom_diff(currNode, nextNode); bodyNode = _VirtualDom_applyPatches(bodyNode, currNode, patches, sendToApp); currNode = nextNode; _VirtualDom_divertHrefToApp = 0; - (title !== doc.gd) && (_VirtualDom_doc.title = title = doc.gd); + (title !== doc.ge) && (_VirtualDom_doc.title = title = doc.ge); }); } ); @@ -4038,12 +4038,12 @@ function _Browser_makeAnimator(model, draw) function _Browser_application(impl) { - var onUrlChange = impl.fy; - var onUrlRequest = impl.fz; + var onUrlChange = impl.fz; + var onUrlRequest = impl.fA; var key = function() { key.a(onUrlChange(_Browser_getUrl())); }; return _Browser_document({ - c3: function(sendToApp) + c4: function(sendToApp) { key.a = sendToApp; _Browser_window.addEventListener('popstate', key); @@ -4059,9 +4059,9 @@ function _Browser_application(impl) var next = $elm$url$Url$fromString(href).a; sendToApp(onUrlRequest( (next - && curr.d$ === next.d$ + && curr.d0 === next.d0 && curr.ax === next.ax - && curr.dX.a === next.dX.a + && curr.dY.a === next.dY.a ) ? $elm$browser$Browser$Internal(next) : $elm$browser$Browser$External(href) @@ -4069,13 +4069,13 @@ function _Browser_application(impl) } }); }, - fk: function(flags) + fl: function(flags) { - return A3(impl.fk, flags, _Browser_getUrl(), key); + return A3(impl.fl, flags, _Browser_getUrl(), key); }, + gj: impl.gj, gi: impl.gi, - gh: impl.gh, - fZ: impl.fZ + f_: impl.f_ }); } @@ -4141,17 +4141,17 @@ var _Browser_decodeEvent = F2(function(decoder, event) function _Browser_visibilityInfo() { return (typeof _VirtualDom_doc.hidden !== 'undefined') - ? { fe: 'hidden', eW: 'visibilitychange' } + ? { ff: 'hidden', eX: 'visibilitychange' } : (typeof _VirtualDom_doc.mozHidden !== 'undefined') - ? { fe: 'mozHidden', eW: 'mozvisibilitychange' } + ? { ff: 'mozHidden', eX: 'mozvisibilitychange' } : (typeof _VirtualDom_doc.msHidden !== 'undefined') - ? { fe: 'msHidden', eW: 'msvisibilitychange' } + ? { ff: 'msHidden', eX: 'msvisibilitychange' } : (typeof _VirtualDom_doc.webkitHidden !== 'undefined') - ? { fe: 'webkitHidden', eW: 'webkitvisibilitychange' } - : { fe: 'hidden', eW: 'visibilitychange' }; + ? { ff: 'webkitHidden', eX: 'webkitvisibilitychange' } + : { ff: 'hidden', eX: 'visibilitychange' }; } @@ -4232,10 +4232,10 @@ var _Browser_call = F2(function(functionName, id) function _Browser_getViewport() { return { - d7: _Browser_getScene(), + d8: _Browser_getScene(), a8: { - es: _Browser_window.pageXOffset, - et: _Browser_window.pageYOffset, + et: _Browser_window.pageXOffset, + eu: _Browser_window.pageYOffset, a9: _Browser_doc.documentElement.clientWidth, aQ: _Browser_doc.documentElement.clientHeight } @@ -4271,13 +4271,13 @@ function _Browser_getViewportOf(id) return _Browser_withNode(id, function(node) { return { - d7: { + d8: { a9: node.scrollWidth, aQ: node.scrollHeight }, a8: { - es: node.scrollLeft, - et: node.scrollTop, + et: node.scrollLeft, + eu: node.scrollTop, a9: node.clientWidth, aQ: node.clientHeight } @@ -4309,16 +4309,16 @@ function _Browser_getElement(id) var x = _Browser_window.pageXOffset; var y = _Browser_window.pageYOffset; return { - d7: _Browser_getScene(), + d8: _Browser_getScene(), a8: { - es: x, - et: y, + et: x, + eu: y, a9: _Browser_doc.documentElement.clientWidth, aQ: _Browser_doc.documentElement.clientHeight }, - e7: { - es: x + rect.left, - et: y + rect.top, + e8: { + et: x + rect.left, + eu: y + rect.top, a9: rect.width, aQ: rect.height } @@ -4365,25 +4365,25 @@ var _Http_toTask = F3(function(router, toTask, request) return _Scheduler_binding(function(callback) { function done(response) { - callback(toTask(request.dt.a(response))); + callback(toTask(request.du.a(response))); } var xhr = new XMLHttpRequest(); xhr.addEventListener('error', function() { done($elm$http$Http$NetworkError_); }); xhr.addEventListener('timeout', function() { done($elm$http$Http$Timeout_); }); - xhr.addEventListener('load', function() { done(_Http_toResponse(request.dt.b, xhr)); }); - $elm$core$Maybe$isJust(request.ek) && _Http_track(router, xhr, request.ek.a); + xhr.addEventListener('load', function() { done(_Http_toResponse(request.du.b, xhr)); }); + $elm$core$Maybe$isJust(request.el) && _Http_track(router, xhr, request.el.a); try { - xhr.open(request.fs, request.en, true); + xhr.open(request.ft, request.eo, true); } catch (e) { - return done($elm$http$Http$BadUrl_(request.en)); + return done($elm$http$Http$BadUrl_(request.eo)); } _Http_configureRequest(xhr, request); - request.eN.a && xhr.setRequestHeader('Content-Type', request.eN.a); - xhr.send(request.eN.b); + request.eO.a && xhr.setRequestHeader('Content-Type', request.eO.a); + xhr.send(request.eO.b); return function() { xhr.c = true; xhr.abort(); }; }); @@ -4394,13 +4394,13 @@ var _Http_toTask = F3(function(router, toTask, request) function _Http_configureRequest(xhr, request) { - for (var headers = request.dx; headers.b; headers = headers.b) // WHILE_CONS + for (var headers = request.dy; headers.b; headers = headers.b) // WHILE_CONS { xhr.setRequestHeader(headers.a.a, headers.a.b); } - xhr.timeout = request.gc.a || 0; - xhr.responseType = request.dt.d; - xhr.withCredentials = request.eF; + xhr.timeout = request.gd.a || 0; + xhr.responseType = request.du.d; + xhr.withCredentials = request.eG; } @@ -4421,10 +4421,10 @@ function _Http_toResponse(toBody, xhr) function _Http_toMetadata(xhr) { return { - en: xhr.responseURL, - fT: xhr.status, - fU: xhr.statusText, - dx: _Http_parseHeaders(xhr.getAllResponseHeaders()) + eo: xhr.responseURL, + fU: xhr.status, + fV: xhr.statusText, + dy: _Http_parseHeaders(xhr.getAllResponseHeaders()) }; } @@ -4519,15 +4519,15 @@ function _Http_track(router, xhr, tracker) xhr.upload.addEventListener('progress', function(event) { if (xhr.c) { return; } _Scheduler_rawSpawn(A2($elm$core$Platform$sendToSelf, router, _Utils_Tuple2(tracker, $elm$http$Http$Sending({ - fL: event.loaded, - ea: event.total + fM: event.loaded, + eb: event.total })))); }); xhr.addEventListener('progress', function(event) { if (xhr.c) { return; } _Scheduler_rawSpawn(A2($elm$core$Platform$sendToSelf, router, _Utils_Tuple2(tracker, $elm$http$Http$Receiving({ - fD: event.loaded, - ea: event.lengthComputable ? $elm$core$Maybe$Just(event.total) : $elm$core$Maybe$Nothing + fE: event.loaded, + eb: event.lengthComputable ? $elm$core$Maybe$Just(event.total) : $elm$core$Maybe$Nothing })))); }); } @@ -5082,7 +5082,7 @@ var $elm$url$Url$Http = 0; var $elm$url$Url$Https = 1; var $elm$url$Url$Url = F6( function (protocol, host, port_, path, query, fragment) { - return {dv: fragment, ax: host, dV: path, dX: port_, d$: protocol, d0: query}; + return {dw: fragment, ax: host, dW: path, dY: port_, d0: protocol, d1: query}; }); var $elm$core$String$contains = _String_contains; var $elm$core$String$length = _String_length; @@ -5373,7 +5373,7 @@ var $author$project$Main$Mapf1Msg = function (a) { }; var $author$project$Main$Model = F4( function (clusterF0, clusterF1, windowInfo, deviceInfo) { - return {O: clusterF0, P: clusterF1, bE: deviceInfo, gj: windowInfo}; + return {O: clusterF0, P: clusterF1, bE: deviceInfo, gk: windowInfo}; }); var $author$project$Main$Window = F2( function (width, height) { @@ -5390,7 +5390,7 @@ var $elm$core$Basics$ge = _Utils_ge; var $elm$core$Basics$round = _Basics_round; var $author$project$Main$calcMapSettings = F2( function (window, _v0) { - var _class = _v0.dm; + var _class = _v0.dn; var orientation = _v0.a$; var preheight = $elm$core$Basics$round(window.aQ * 0.93); var prewidth = $elm$core$Basics$round(preheight * 0.63); @@ -5435,7 +5435,7 @@ var $elm$core$Basics$min = F2( }); var $author$project$Main$classifyDevice = function (window) { return { - dm: function () { + dn: function () { var shortSide = A2($elm$core$Basics$min, window.a9, window.aQ); var longSide = A2($elm$core$Basics$max, window.a9, window.aQ); return (shortSide < 600) ? 0 : ((longSide <= 1200) ? 1 : (((longSide > 1200) && (longSide <= 1800)) ? 2 : 3)); @@ -6046,7 +6046,7 @@ var $elm$http$Http$resolve = F2( case 3: var metadata = response.a; return $elm$core$Result$Err( - $elm$http$Http$BadStatus(metadata.fT)); + $elm$http$Http$BadStatus(metadata.fU)); default: var body = response.b; return A2( @@ -6074,7 +6074,7 @@ var $elm$http$Http$Request = function (a) { }; var $elm$http$Http$State = F2( function (reqs, subs) { - return {d2: reqs, eb: subs}; + return {d3: reqs, ec: subs}; }); var $elm$http$Http$init = $elm$core$Task$succeed( A2($elm$http$Http$State, $elm$core$Dict$empty, _List_Nil)); @@ -6118,7 +6118,7 @@ var $elm$http$Http$updateReqs = F3( return A2( $elm$core$Task$andThen, function (pid) { - var _v4 = req.ek; + var _v4 = req.el; if (_v4.$ === 1) { return A3($elm$http$Http$updateReqs, router, otherCmds, reqs); } else { @@ -6148,7 +6148,7 @@ var $elm$http$Http$onEffects = F4( return $elm$core$Task$succeed( A2($elm$http$Http$State, reqs, subs)); }, - A3($elm$http$Http$updateReqs, router, cmds, state.d2)); + A3($elm$http$Http$updateReqs, router, cmds, state.d3)); }); var $elm$core$List$maybeCons = F3( function (f, mx, xs) { @@ -6191,7 +6191,7 @@ var $elm$http$Http$onSelfMsg = F3( A2( $elm$core$List$filterMap, A3($elm$http$Http$maybeSend, router, tracker, progress), - state.eb))); + state.ec))); }); var $elm$http$Http$Cancel = function (a) { return {$: 0, a: a}; @@ -6205,14 +6205,14 @@ var $elm$http$Http$cmdMap = F2( var r = cmd.a; return $elm$http$Http$Request( { - eF: r.eF, - eN: r.eN, - dt: A2(_Http_mapExpect, func, r.dt), - dx: r.dx, - fs: r.fs, - gc: r.gc, - ek: r.ek, - en: r.en + eG: r.eG, + eO: r.eO, + du: A2(_Http_mapExpect, func, r.du), + dy: r.dy, + ft: r.ft, + gd: r.gd, + el: r.el, + eo: r.eo }); } }); @@ -6235,11 +6235,11 @@ var $elm$http$Http$subscription = _Platform_leaf('Http'); var $elm$http$Http$request = function (r) { return $elm$http$Http$command( $elm$http$Http$Request( - {eF: false, eN: r.eN, dt: r.dt, dx: r.dx, fs: r.fs, gc: r.gc, ek: r.ek, en: r.en})); + {eG: false, eO: r.eO, du: r.du, dy: r.dy, ft: r.ft, gd: r.gd, el: r.el, eo: r.eo})); }; var $elm$http$Http$get = function (r) { return $elm$http$Http$request( - {eN: $elm$http$Http$emptyBody, dt: r.dt, dx: _List_Nil, fs: 'GET', gc: $elm$core$Maybe$Nothing, ek: $elm$core$Maybe$Nothing, en: r.en}); + {eO: $elm$http$Http$emptyBody, du: r.du, dy: _List_Nil, ft: 'GET', gd: $elm$core$Maybe$Nothing, el: $elm$core$Maybe$Nothing, eo: r.eo}); }; var $elm$json$Json$Decode$list = _Json_decodeList; var $elm$json$Json$Decode$oneOf = _Json_oneOf; @@ -6259,7 +6259,7 @@ var $webbhuset$elm_json_decode$Json$Decode$Field$attempt = F3( $elm$json$Json$Decode$maybe( A2($elm$json$Json$Decode$field, fieldName, valueDecoder))); }); -var $author$project$Clustermap$user_photos_domain = '/api/images/'; +var $author$project$Clustermap$user_photos_domain = '/api/images'; var $author$project$Clustermap$getInitialImage = function (username) { return $author$project$Clustermap$user_photos_domain + ('?login=' + username); }; @@ -6271,6 +6271,15 @@ var $webbhuset$elm_json_decode$Json$Decode$Field$require = F3( A2($elm$json$Json$Decode$field, fieldName, valueDecoder)); }); var $elm$json$Json$Decode$string = _Json_decodeString; +var $elm$core$Maybe$withDefault = F2( + function (_default, maybe) { + if (!maybe.$) { + var value = maybe.a; + return value; + } else { + return _default; + } + }); var $author$project$Clustermap$sessionDecoder = function (sessionlist) { return A3( $webbhuset$elm_json_decode$Json$Decode$Field$require, @@ -6284,29 +6293,38 @@ var $author$project$Clustermap$sessionDecoder = function (sessionlist) { function (sessionType) { return A3( $webbhuset$elm_json_decode$Json$Decode$Field$attempt, - 'login', - $elm$json$Json$Decode$string, - function (maybeUsername) { - if (!maybeUsername.$) { - var username = maybeUsername.a; - return $elm$json$Json$Decode$succeed( - { - aa: true, - ax: host, - bP: $author$project$Clustermap$getInitialImage(username), - a1: sessionType, - aH: username - }); - } else { - return $elm$json$Json$Decode$succeed( - { - aa: false, - ax: host, - bP: $author$project$Clustermap$getInitialImage(''), - a1: sessionType, - aH: '' - }); - } + 'isTranscender', + $elm$json$Json$Decode$bool, + function (maybeTranscender) { + return A3( + $webbhuset$elm_json_decode$Json$Decode$Field$attempt, + 'login', + $elm$json$Json$Decode$string, + function (maybeUsername) { + var isTranscender = A2($elm$core$Maybe$withDefault, false, maybeTranscender); + if (!maybeUsername.$) { + var username = maybeUsername.a; + return $elm$json$Json$Decode$succeed( + { + aa: true, + ax: host, + bP: $author$project$Clustermap$getInitialImage(username), + bS: isTranscender, + a1: sessionType, + aH: username + }); + } else { + return $elm$json$Json$Decode$succeed( + { + aa: false, + ax: host, + bP: $author$project$Clustermap$getInitialImage(''), + bS: isTranscender, + a1: sessionType, + aH: '' + }); + } + }); }); }); }); @@ -6323,11 +6341,11 @@ var $author$project$Clustermap$getActiveSessions = F2( function (endpoint, sessionlist) { return $elm$http$Http$get( { - dt: A2( + du: A2( $elm$http$Http$expectJson, $author$project$Clustermap$GotSessions, $author$project$Clustermap$sessionListDecoder(sessionlist)), - en: $author$project$Endpoint$toString(endpoint) + eo: $author$project$Endpoint$toString(endpoint) }); }); var $author$project$Clustermap$GotHosts = function (a) { @@ -6335,10 +6353,10 @@ var $author$project$Clustermap$GotHosts = function (a) { }; var $rundis$elm_bootstrap$Bootstrap$Popover$State = $elm$core$Basics$identity; var $rundis$elm_bootstrap$Bootstrap$Popover$initialState = { - cK: { - cV: 0, - b$: 0, - c$: {aQ: 0, dJ: 0, ej: 0, a9: 0} + cL: { + cW: 0, + b0: 0, + c0: {aQ: 0, dK: 0, ek: 0, a9: 0} }, aA: false }; @@ -6362,7 +6380,7 @@ var $author$project$Clustermap$hostDecoder = A3( { i: hostname, I: $rundis$elm_bootstrap$Bootstrap$Popover$initialState, - cb: _Utils_Tuple2(left, top) + cc: _Utils_Tuple2(left, top) }); }); }); @@ -6411,14 +6429,14 @@ var $author$project$Clustermap$hostModelDecoder = A3( var $author$project$Clustermap$getHosts = function (endpoint) { return $elm$http$Http$get( { - dt: A2($elm$http$Http$expectJson, $author$project$Clustermap$GotHosts, $author$project$Clustermap$hostModelDecoder), - en: $author$project$Endpoint$toString(endpoint) + du: A2($elm$http$Http$expectJson, $author$project$Clustermap$GotHosts, $author$project$Clustermap$hostModelDecoder), + eo: $author$project$Endpoint$toString(endpoint) }); }; var $author$project$Clustermap$init = F4( function (hostEndpoint, sessionEndpoint, image, mapsettings) { return _Utils_Tuple2( - {L: _List_Nil, dA: hostEndpoint, V: $elm$core$Maybe$Nothing, bV: image, e: mapsettings, aE: $author$project$Clustermap$HostLoading, aF: $author$project$Clustermap$Loading, c2: sessionEndpoint}, + {L: _List_Nil, dB: hostEndpoint, V: $elm$core$Maybe$Nothing, bW: image, e: mapsettings, aE: $author$project$Clustermap$HostLoading, aF: $author$project$Clustermap$Loading, c3: sessionEndpoint}, $elm$core$Platform$Cmd$batch( _List_fromArray( [ @@ -6431,7 +6449,7 @@ var $elm$core$Platform$Cmd$none = $elm$core$Platform$Cmd$batch(_List_Nil); var $author$project$Main$init = function (_v0) { var width = _v0.a9; var height = _v0.aQ; - var isFirefox = _v0.dF; + var isFirefox = _v0.dG; var width90 = $elm$core$Basics$round(width * 0.9); var resizeTask = isFirefox ? $elm$core$Platform$Cmd$none : A2($elm$core$Task$perform, $author$project$Main$BeResponsive, $elm$browser$Browser$Dom$getViewport); var height90 = $elm$core$Basics$round(height * 0.9); @@ -6476,7 +6494,7 @@ var $elm$time$Time$Every = F2( }); var $elm$time$Time$State = F2( function (taggers, processes) { - return {d_: processes, ec: taggers}; + return {d$: processes, ed: taggers}; }); var $elm$time$Time$init = $elm$core$Task$succeed( A2($elm$time$Time$State, $elm$core$Dict$empty, $elm$core$Dict$empty)); @@ -6623,7 +6641,7 @@ var $elm$time$Time$spawnHelp = F3( }); var $elm$time$Time$onEffects = F3( function (router, subs, _v0) { - var processes = _v0.d_; + var processes = _v0.d$; var rightStep = F3( function (_v6, id, _v7) { var spawns = _v7.a; @@ -6692,7 +6710,7 @@ var $elm$time$Time$millisToPosix = $elm$core$Basics$identity; var $elm$time$Time$now = _Time_now($elm$time$Time$millisToPosix); var $elm$time$Time$onSelfMsg = F3( function (router, interval, state) { - var _v0 = A2($elm$core$Dict$get, interval, state.ec); + var _v0 = A2($elm$core$Dict$get, interval, state.ed); if (_v0.$ === 1) { return $elm$core$Task$succeed(state); } else { @@ -6746,7 +6764,7 @@ var $elm$browser$Browser$Events$MySub = F3( }); var $elm$browser$Browser$Events$State = F2( function (subs, pids) { - return {dW: pids, eb: subs}; + return {dX: pids, ec: subs}; }); var $elm$browser$Browser$Events$init = $elm$core$Task$succeed( A2($elm$browser$Browser$Events$State, _List_Nil, $elm$core$Dict$empty)); @@ -6780,7 +6798,7 @@ var $elm$core$Dict$fromList = function (assocs) { }; var $elm$browser$Browser$Events$Event = F2( function (key, event) { - return {ds: event, dI: key}; + return {dt: event, dJ: key}; }); var $elm$browser$Browser$Events$spawn = F3( function (router, key, _v0) { @@ -6854,7 +6872,7 @@ var $elm$browser$Browser$Events$onEffects = F3( stepLeft, stepBoth, stepRight, - state.dW, + state.dX, $elm$core$Dict$fromList(newSubs), _Utils_Tuple3(_List_Nil, $elm$core$Dict$empty, _List_Nil)); var deadPids = _v0.a; @@ -6882,8 +6900,8 @@ var $elm$browser$Browser$Events$onEffects = F3( }); var $elm$browser$Browser$Events$onSelfMsg = F3( function (router, _v0, state) { - var key = _v0.dI; - var event = _v0.ds; + var key = _v0.dJ; + var event = _v0.dt; var toMessage = function (_v2) { var subKey = _v2.a; var _v3 = _v2.b; @@ -6892,7 +6910,7 @@ var $elm$browser$Browser$Events$onSelfMsg = F3( var decoder = _v3.c; return _Utils_eq(subKey, key) ? A2(_Browser_decodeEvent, decoder, event) : $elm$core$Maybe$Nothing; }; - var messages = A2($elm$core$List$filterMap, toMessage, state.eb); + var messages = A2($elm$core$List$filterMap, toMessage, state.ec); return A2( $elm$core$Task$andThen, function (_v1) { @@ -7013,7 +7031,7 @@ var $author$project$Clustermap$update = F2( case 2: return _Utils_Tuple2( model, - A2($author$project$Clustermap$getActiveSessions, model.c2, model.L)); + A2($author$project$Clustermap$getActiveSessions, model.c3, model.L)); default: var host = msg.a; var state = msg.b; @@ -7227,7 +7245,7 @@ var $rundis$elm_bootstrap$Bootstrap$Internal$Button$applyModifier = F2( return _Utils_update( options, { - ea: $elm$core$Maybe$Just(size) + eb: $elm$core$Maybe$Just(size) }); case 1: var coloring = modifier.a; @@ -7279,7 +7297,7 @@ var $elm$html$Html$Attributes$classList = function (classes) { $elm$core$Tuple$first, A2($elm$core$List$filter, $elm$core$Tuple$second, classes)))); }; -var $rundis$elm_bootstrap$Bootstrap$Internal$Button$defaultOptions = {ab: _List_Nil, br: false, Q: $elm$core$Maybe$Nothing, bG: false, ea: $elm$core$Maybe$Nothing}; +var $rundis$elm_bootstrap$Bootstrap$Internal$Button$defaultOptions = {ab: _List_Nil, br: false, Q: $elm$core$Maybe$Nothing, bG: false, eb: $elm$core$Maybe$Nothing}; var $elm$json$Json$Encode$bool = _Json_wrap; var $elm$html$Html$Attributes$boolProperty = F2( function (key, bool) { @@ -7341,7 +7359,7 @@ var $rundis$elm_bootstrap$Bootstrap$Internal$Button$buttonAttributes = function ]), _Utils_ap( function () { - var _v0 = A2($elm$core$Maybe$andThen, $rundis$elm_bootstrap$Bootstrap$General$Internal$screenSizeOption, options.ea); + var _v0 = A2($elm$core$Maybe$andThen, $rundis$elm_bootstrap$Bootstrap$General$Internal$screenSizeOption, options.eb); if (!_v0.$) { var s = _v0.a; return _List_fromArray( @@ -7395,7 +7413,7 @@ var $author$project$Clustermap$calculateTop = F3( var $rundis$elm_bootstrap$Bootstrap$Popover$Config = $elm$core$Basics$identity; var $rundis$elm_bootstrap$Bootstrap$Popover$Top = 0; var $rundis$elm_bootstrap$Bootstrap$Popover$config = function (triggerElement) { - return {e0: $elm$core$Maybe$Nothing, T: 0, gd: $elm$core$Maybe$Nothing, em: triggerElement}; + return {e1: $elm$core$Maybe$Nothing, T: 0, ge: $elm$core$Maybe$Nothing, en: triggerElement}; }; var $rundis$elm_bootstrap$Bootstrap$Popover$Content = $elm$core$Basics$identity; var $rundis$elm_bootstrap$Bootstrap$Popover$content = F3( @@ -7404,7 +7422,7 @@ var $rundis$elm_bootstrap$Bootstrap$Popover$content = F3( return _Utils_update( conf, { - e0: $elm$core$Maybe$Just( + e1: $elm$core$Maybe$Just( A2( $elm$html$Html$div, A2( @@ -7517,15 +7535,6 @@ var $author$project$Clustermap$hostFilter = F2( function (host, session) { return A2($elm$core$String$contains, host, session.ax); }); -var $elm$core$Maybe$withDefault = F2( - function (_default, maybe) { - if (!maybe.$) { - var value = maybe.a; - return value; - } else { - return _default; - } - }); var $author$project$Clustermap$hostToId = function (host) { return A2( $elm$core$Maybe$withDefault, @@ -7563,7 +7572,7 @@ var $elm$html$Html$Events$on = F2( var $elm$core$Basics$not = _Basics_not; var $rundis$elm_bootstrap$Bootstrap$Popover$DOMState = F3( function (rect, offsetWidth, offsetHeight) { - return {cV: offsetHeight, b$: offsetWidth, c$: rect}; + return {cW: offsetHeight, b0: offsetWidth, c0: rect}; }); var $elm$json$Json$Decode$map3 = _Json_map3; var $elm$json$Json$Decode$float = _Json_decodeFloat; @@ -7676,7 +7685,7 @@ var $rundis$elm_bootstrap$Bootstrap$Utilities$DomHelper$boundingArea = A4( function (_v0, width, height) { var x = _v0.a; var y = _v0.b; - return {aQ: height, dJ: x, ej: y, a9: width}; + return {aQ: height, dK: x, ek: y, a9: width}; }), A2($rundis$elm_bootstrap$Bootstrap$Utilities$DomHelper$position, 0, 0), $rundis$elm_bootstrap$Bootstrap$Utilities$DomHelper$offsetWidth, @@ -7741,7 +7750,7 @@ var $rundis$elm_bootstrap$Bootstrap$Popover$toggleState = F2( function (v) { return $elm$json$Json$Decode$succeed( toMsg( - (!isActive) ? {cK: v, aA: true} : _Utils_update( + (!isActive) ? {cL: v, aA: true} : _Utils_update( state, {aA: false}))); }, @@ -7770,7 +7779,7 @@ var $rundis$elm_bootstrap$Bootstrap$Popover$titlePrivate = F4( return _Utils_update( conf, { - gd: $elm$core$Maybe$Just( + ge: $elm$core$Maybe$Just( A2( elemFn, A2( @@ -7792,37 +7801,37 @@ var $elm$core$Basics$negate = function (n) { }; var $rundis$elm_bootstrap$Bootstrap$Popover$calculatePos = F2( function (pos, _v0) { - var rect = _v0.c$; - var offsetWidth = _v0.b$; - var offsetHeight = _v0.cV; + var rect = _v0.c0; + var offsetWidth = _v0.b0; + var offsetHeight = _v0.cW; switch (pos) { case 3: return { ar: $elm$core$Maybe$Nothing, as: $elm$core$Maybe$Just((offsetHeight / 2) - 12), - dJ: (-offsetWidth) - 10, - ej: (rect.aQ / 2) - (offsetHeight / 2) + dK: (-offsetWidth) - 10, + ek: (rect.aQ / 2) - (offsetHeight / 2) }; case 1: return { ar: $elm$core$Maybe$Nothing, as: $elm$core$Maybe$Just((offsetHeight / 2) - 12), - dJ: rect.a9, - ej: (rect.aQ / 2) - (offsetHeight / 2) + dK: rect.a9, + ek: (rect.aQ / 2) - (offsetHeight / 2) }; case 0: return { ar: $elm$core$Maybe$Just((offsetWidth / 2) - 12), as: $elm$core$Maybe$Nothing, - dJ: (rect.a9 / 2) - (offsetWidth / 2), - ej: (-offsetHeight) - 10 + dK: (rect.a9 / 2) - (offsetWidth / 2), + ek: (-offsetHeight) - 10 }; default: return { ar: $elm$core$Maybe$Just((offsetWidth / 2) - 12), as: $elm$core$Maybe$Nothing, - dJ: (rect.a9 / 2) - (offsetWidth / 2), - ej: rect.aQ + dK: (rect.a9 / 2) - (offsetWidth / 2), + ek: rect.aQ }; } }); @@ -7877,7 +7886,7 @@ var $rundis$elm_bootstrap$Bootstrap$Popover$positionClass = function (position) var $rundis$elm_bootstrap$Bootstrap$Popover$popoverView = F2( function (_v0, _v1) { var isActive = _v0.aA; - var domState = _v0.cK; + var domState = _v0.cL; var conf = _v1; var px = function (f) { return $elm$core$String$fromFloat(f) + 'px'; @@ -7888,16 +7897,16 @@ var $rundis$elm_bootstrap$Bootstrap$Popover$popoverView = F2( A2( $elm$html$Html$Attributes$style, 'left', - px(pos.dJ)), + px(pos.dK)), A2( $elm$html$Html$Attributes$style, 'top', - px(pos.ej)), + px(pos.ek)), A2($elm$html$Html$Attributes$style, 'display', 'inline-block'), A2( $elm$html$Html$Attributes$style, 'width', - px(domState.b$)) + px(domState.b0)) ]) : _List_fromArray( [ A2($elm$html$Html$Attributes$style, 'left', '-5000px'), @@ -7962,19 +7971,19 @@ var $rundis$elm_bootstrap$Bootstrap$Popover$popoverView = F2( var t = _v2; return t; }, - conf.gd), + conf.ge), A2( $elm$core$Maybe$map, function (_v3) { var c = _v3; return c; }, - conf.e0) + conf.e1) ]))); }); var $rundis$elm_bootstrap$Bootstrap$Popover$view = F2( function (state, conf) { - var triggerElement = conf.em; + var triggerElement = conf.en; return A2( $elm$html$Html$div, _List_fromArray( @@ -8012,12 +8021,12 @@ var $author$project$Clustermap$viewIcon = F4( $elm$html$Html$Attributes$style, 'left', $elm$core$String$fromInt( - A3($author$project$Clustermap$calculateLeft, model, hostmapsettings, host.cb.a) - offset) + 'px'), + A3($author$project$Clustermap$calculateLeft, model, hostmapsettings, host.cc.a) - offset) + 'px'), A2( $elm$html$Html$Attributes$style, 'top', $elm$core$String$fromInt( - A3($author$project$Clustermap$calculateTop, model, hostmapsettings, host.cb.b) - offset) + 'px') + A3($author$project$Clustermap$calculateTop, model, hostmapsettings, host.cc.b) - offset) + 'px') ]), function () { if (maybeSession.$ === 1) { @@ -8113,7 +8122,8 @@ var $author$project$Clustermap$viewIcon = F4( _List_fromArray( [ _Utils_Tuple2('round-img', true), - _Utils_Tuple2('session-' + session.a1, true) + _Utils_Tuple2('session-' + session.a1, true), + _Utils_Tuple2('session-transcender', session.bS) ])), A2( $elm$html$Html$Attributes$style, @@ -8199,13 +8209,13 @@ var $author$project$Clustermap$viewMap = F3( A2( $elm$core$List$cons, _Utils_Tuple2( - $author$project$Asset$toString(model.bV), + $author$project$Asset$toString(model.bW), A2( $elm$html$Html$img, _List_fromArray( [ $elm$html$Html$Attributes$src( - $author$project$Asset$toString(model.bV)), + $author$project$Asset$toString(model.bW)), A2($elm$html$Html$Attributes$style, 'position', 'relative'), A2( $elm$html$Html$Attributes$style, @@ -8442,7 +8452,7 @@ var $author$project$Main$view = function (model) { ])); }; var $author$project$Main$main = $elm$browser$Browser$element( - {fk: $author$project$Main$init, fZ: $author$project$Main$subscriptions, gh: $author$project$Main$update, gi: $author$project$Main$view}); + {fl: $author$project$Main$init, f_: $author$project$Main$subscriptions, gi: $author$project$Main$update, gj: $author$project$Main$view}); _Platform_export({'Main':{'init':$author$project$Main$main( A2( $elm$json$Json$Decode$andThen, @@ -8454,7 +8464,7 @@ _Platform_export({'Main':{'init':$author$project$Main$main( $elm$json$Json$Decode$andThen, function (height) { return $elm$json$Json$Decode$succeed( - {aQ: height, dF: isFirefox, a9: width}); + {aQ: height, dG: isFirefox, a9: width}); }, A2($elm$json$Json$Decode$field, 'height', $elm$json$Json$Decode$int)); }, diff --git a/src/elm/clustermap/Clustermap.elm b/src/elm/clustermap/Clustermap.elm index 008898a..61b9c00 100644 --- a/src/elm/clustermap/Clustermap.elm +++ b/src/elm/clustermap/Clustermap.elm @@ -83,6 +83,7 @@ type alias Session = , imageSrc : String , alive : Bool , sessionType : String + , isTranscender : Bool } type alias Model = @@ -326,6 +327,7 @@ viewIcon model sessionlist hostmapsettings host = , classList [ ( "round-img", True ) , ( "session-" ++ session.sessionType, True ) + , ( "session-transcender", session.isTranscender ) ] , style "width" <| String.fromInt model.mapSettings.activeIconSize @@ -569,22 +571,30 @@ sessionDecoder sessionlist = \host -> Field.require "sessionType" Decode.string <| \sessionType -> - Field.attempt "login" Decode.string <| - \maybeUsername -> - case maybeUsername of - Just username -> - Decode.succeed - { username = username - , host = host - , imageSrc = (getInitialImage username) - , alive = True - , sessionType = sessionType - } - Nothing -> - Decode.succeed - { username = "" - , host = host - , imageSrc = (getInitialImage "") - , alive = False - , sessionType = sessionType - } + Field.attempt "isTranscender" Decode.bool <| + \maybeTranscender -> + Field.attempt "login" Decode.string <| + \maybeUsername -> + let + isTranscender = + Maybe.withDefault False maybeTranscender + in + case maybeUsername of + Just username -> + Decode.succeed + { username = username + , host = host + , imageSrc = (getInitialImage username) + , alive = True + , sessionType = sessionType + , isTranscender = isTranscender + } + Nothing -> + Decode.succeed + { username = "" + , host = host + , imageSrc = (getInitialImage "") + , alive = False + , sessionType = sessionType + , isTranscender = isTranscender + } diff --git a/src/pages/api/active.ts b/src/pages/api/active.ts index 815fd10..512921f 100644 --- a/src/pages/api/active.ts +++ b/src/pages/api/active.ts @@ -4,6 +4,7 @@ import NodeCache from "node-cache"; import { PrismaClient as CrsClient } from "../../server/db/crsClient"; import { Workstation } from "../../server/db/crsClient"; import { PrismaClient as ExamClient } from "../../server/db/examClient"; +import { getTranscenderLogins } from "../../server/intra42"; const crsPrisma = new CrsClient(); const examPrisma = new ExamClient(); @@ -72,6 +73,7 @@ interface ResponseLocation { hostname: string; sessionType: 'normal' | 'exam' | 'dead'; alive: boolean; + isTranscender?: boolean; } async function getCRLocations() { @@ -213,6 +215,19 @@ const locations = async (req: NextApiRequest, res: NextApiResponse) => { } } + // Enrich sessions with "Transcender" title information from the 42 Intra + // API. The lookup is cached for an hour inside the intra42 module, so this + // is essentially free on warm requests. If credentials are missing or the + // API call fails, every isTranscender stays false and the map still works. + const transcenderLogins = await getTranscenderLogins(); + if (transcenderLogins.size > 0) { + for (const location of responseJSON) { + if (location.login && transcenderLogins.has(location.login)) { + location.isTranscender = true; + } + } + } + // Store locations in cache locationCache.set("response", responseJSON, 5); response = responseJSON; diff --git a/src/server/intra42.ts b/src/server/intra42.ts new file mode 100644 index 0000000..4fec386 --- /dev/null +++ b/src/server/intra42.ts @@ -0,0 +1,144 @@ +// Minimal 42 Intra API client used to enrich the active-sessions response +// with information that is not available in the Cluster Reporter or +// Exam-master databases. +// +// Currently the only enrichment is the "Transcender" title: students who +// hold it are eligible for the advanced part of the curriculum and get a +// distinct ring colour on the clustermap. +// +// Auth flow: client_credentials (app-only). Required env vars: +// INTRA_42_UID +// INTRA_42_SECRET +// Optional env vars: +// TRANSCENDER_TITLE_NAME (default: "Transcender of %login") +// +// If credentials are missing the client is considered "not configured" and +// every consumer falls back to "no transcender data", keeping the map fully +// functional without any 42 API access (useful for first-boot or offline dev). + +import NodeCache from "node-cache"; + +const TOKEN_URL = "https://api.intra.42.fr/oauth/token"; +const API_BASE = "https://api.intra.42.fr/v2"; +const TITLE_NAME = process.env.TRANSCENDER_TITLE_NAME || "Transcender of %login"; + +// Caches keyed by a fixed string; TTLs in seconds. +const cache = new NodeCache(); +const TOKEN_KEY = "token"; +const TITLE_ID_KEY = "titleId"; +const LOGINS_KEY = "transcenderLogins"; +const LOGINS_TTL = 60 * 60; // 1 hour + +interface TokenResponse { + access_token: string; + expires_in: number; // seconds +} + +interface Title { + id: number; + name: string; +} + +interface IntraUser { + id: number; + login: string; +} + +export function isConfigured(): boolean { + return !!(process.env.INTRA_42_UID && process.env.INTRA_42_SECRET); +} + +async function getToken(): Promise { + const cached = cache.get(TOKEN_KEY); + if (cached) return cached; + + const body = new URLSearchParams({ + grant_type: "client_credentials", + client_id: process.env.INTRA_42_UID || "", + client_secret: process.env.INTRA_42_SECRET || "", + scope: "public", + }); + + const res = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + if (!res.ok) { + throw new Error(`Intra token request failed: ${res.status} ${res.statusText}`); + } + const data = (await res.json()) as TokenResponse; + // Refresh ~60s before actual expiry to avoid races. + const ttl = Math.max(60, data.expires_in - 60); + cache.set(TOKEN_KEY, data.access_token, ttl); + return data.access_token; +} + +async function apiGet(path: string): Promise<{ data: T; headers: Headers }> { + const token = await getToken(); + const res = await fetch(`${API_BASE}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + throw new Error(`Intra GET ${path} failed: ${res.status} ${res.statusText}`); + } + return { data: (await res.json()) as T, headers: res.headers }; +} + +async function getTranscenderTitleId(): Promise { + const cached = cache.get(TITLE_ID_KEY); + if (cached !== undefined) return cached; + + // Exact-name lookup. If Codam ever renames the title, set TRANSCENDER_TITLE_NAME. + const path = `/titles?filter[name]=${encodeURIComponent(TITLE_NAME)}`; + const { data } = await apiGet(path); + const id = data.length > 0 ? data[0]!.id : null; + // Title ids never change; cache for a long time. + cache.set(TITLE_ID_KEY, id, 24 * 60 * 60); + return id; +} + +async function fetchAllUsersOfTitle(titleId: number): Promise { + const pageSize = 100; + const all: IntraUser[] = []; + for (let page = 1; ; page++) { + const path = `/titles/${titleId}/users?page[size]=${pageSize}&page[number]=${page}`; + const { data } = await apiGet(path); + all.push(...data); + if (data.length < pageSize) break; + // Safety stop; we never expect thousands of holders for a single title. + if (page > 50) break; + } + return all; +} + +/** + * Returns the set of intra logins that currently hold the Transcender title. + * Returns an empty Set when the client is not configured or the title cannot + * be resolved — callers should treat that as "no enrichment available". + */ +export async function getTranscenderLogins(): Promise> { + if (!isConfigured()) return new Set(); + + const cached = cache.get(LOGINS_KEY); + if (cached) return new Set(cached); + + try { + const titleId = await getTranscenderTitleId(); + if (titleId === null) { + console.warn(`[intra42] Title not found: ${TITLE_NAME}`); + cache.set(LOGINS_KEY, [], LOGINS_TTL); + return new Set(); + } + const users = await fetchAllUsersOfTitle(titleId); + const logins = users.map((u) => u.login); + cache.set(LOGINS_KEY, logins, LOGINS_TTL); + console.log(`[intra42] Refreshed transcender logins (${logins.length})`); + return new Set(logins); + } catch (err) { + console.error(`[intra42] Failed to fetch transcender logins:`, err); + // Cache an empty set briefly so a flaky API doesn't hammer us. + cache.set(LOGINS_KEY, [], 60); + return new Set(); + } +}