From c1a93bcb5a3857ddef41b1da8ab594c3846d61ce Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 13:21:48 +0200 Subject: [PATCH 01/94] Updated gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 66ccaac..65d40f8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ /test.ts /sessions/ *.swp +.sync-timestamp +/src/generated/prisma From e0f70b3d1521b55000328b47a39c824964bb1b17 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 13:26:08 +0200 Subject: [PATCH 02/94] Change json database into sqlite --- .gitignore | 2 +- database/campus.sql | 7 +++++++ database/project.sql | 8 ++++++++ database/project_user.sql | 14 ++++++++++++++ database/sync.sql | 6 ++++++ database/user.sql | 11 +++++++++++ 6 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 database/campus.sql create mode 100644 database/project.sql create mode 100644 database/project_user.sql create mode 100644 database/sync.sql create mode 100644 database/user.sql diff --git a/.gitignore b/.gitignore index 65d40f8..820bb9e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ /node_modules/ /token*.json /env/.env -/database*/ +database/*.db /test.ts /sessions/ *.swp diff --git a/database/campus.sql b/database/campus.sql new file mode 100644 index 0000000..ad6295d --- /dev/null +++ b/database/campus.sql @@ -0,0 +1,7 @@ +PRAGMA foreign_keys=ON; +BEGIN TRANSACTION; +CREATE TABLE campus( + id INT PRIMARY KEY, + name text + ); +COMMIT; diff --git a/database/project.sql b/database/project.sql new file mode 100644 index 0000000..78bc31c --- /dev/null +++ b/database/project.sql @@ -0,0 +1,8 @@ +PRAGMA foreign_keys=ON; +BEGIN TRANSACTION; +CREATE TABLE project( + id INT PRIMARY KEY, + slug text, + name text + ); +COMMIT; diff --git a/database/project_user.sql b/database/project_user.sql new file mode 100644 index 0000000..b8e3e6a --- /dev/null +++ b/database/project_user.sql @@ -0,0 +1,14 @@ +PRAGMA foreign_keys=ON; +BEGIN TRANSACTION; +CREATE TABLE project_user( + project_id integer, + user_id integer, + created_at timestamp, + updated_at timestamp, + validated_at timestamp, + status text, + PRIMARY KEY (project_id, user_id), + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (project_id) REFERENCES project(id) + ); +COMMIT; diff --git a/database/sync.sql b/database/sync.sql new file mode 100644 index 0000000..92dd3e5 --- /dev/null +++ b/database/sync.sql @@ -0,0 +1,6 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE sync( + last_pull timestamp, + ); +COMMIT; \ No newline at end of file diff --git a/database/user.sql b/database/user.sql new file mode 100644 index 0000000..742b092 --- /dev/null +++ b/database/user.sql @@ -0,0 +1,11 @@ +PRAGMA foreign_keys=ON; +BEGIN TRANSACTION; +CREATE TABLE user( + id INT PRIMARY KEY, + login UNIQUE text, + primary_campus_id integer, + image_url text, + anonymize_date timestamp, + FOREIGN KEY (primary_campus_id) REFERENCES campus(id) + ); +COMMIT; From d0ec237354dc6fb987f9617b3850fa6c366d2b16 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 13:30:09 +0200 Subject: [PATCH 03/94] Added prisma migration --- .gitignore | 2 +- .../migration.sql | 45 +++++++++++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 57 +++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20250813140133_initial_setup/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma diff --git a/.gitignore b/.gitignore index 820bb9e..9ecec26 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ database/*.db /sessions/ *.swp .sync-timestamp -/src/generated/prisma +/prisma/*.db diff --git a/prisma/migrations/20250813140133_initial_setup/migration.sql b/prisma/migrations/20250813140133_initial_setup/migration.sql new file mode 100644 index 0000000..67b9fec --- /dev/null +++ b/prisma/migrations/20250813140133_initial_setup/migration.sql @@ -0,0 +1,45 @@ +-- CreateTable +CREATE TABLE "Campus" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "login" TEXT NOT NULL, + "primary_campus_id" INTEGER, + "image_url" TEXT, + "anonymize_date" TEXT, + CONSTRAINT "User_primary_campus_id_fkey" FOREIGN KEY ("primary_campus_id") REFERENCES "Campus" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ProjectUser" ( + "project_id" INTEGER NOT NULL, + "user_id" INTEGER NOT NULL, + "created_at" TEXT NOT NULL, + "updated_at" TEXT NOT NULL, + "validated_at" TEXT, + "status" TEXT NOT NULL, + + PRIMARY KEY ("project_id", "user_id"), + CONSTRAINT "ProjectUser_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "Project" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "ProjectUser_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Sync" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "last_pull" TEXT +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_login_key" ON "User"("login"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..a952c33 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,57 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = "file:./myfindpeers.db" +} + +model Campus { + id Int @id + name String + users User[] +} + +model Project { + id Int @id + slug String + name String + users ProjectUser[] +} + +model User { + id Int @id + login String @unique + primary_campus_id Int? + image_url String? + anonymize_date String? + + campus Campus? @relation(fields: [primary_campus_id], references: [id]) + projects ProjectUser[] +} + +model ProjectUser { + project_id Int + user_id Int + created_at String + updated_at String + validated_at String? + status String + + project Project @relation(fields: [project_id], references: [id]) + user User @relation(fields: [user_id], references: [id]) + + @@id([project_id, user_id]) +} + +model Sync { + id Int @id @default(autoincrement()) + last_pull String? +} From 855cc2ee238c28f938d18e88448420f8f1952fe4 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 13:46:17 +0200 Subject: [PATCH 04/94] Does not store entire user profile anymore, now only stores an access token for authentication --- package-lock.json | 45 +++++++++++++++ package.json | 3 + src/authentication.ts | 129 +++++++++++++++++------------------------- 3 files changed, 100 insertions(+), 77 deletions(-) diff --git a/package-lock.json b/package-lock.json index 71fc9d6..a3fc026 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,9 @@ "request": "^2.88.2", "typescript": "4.9.5" }, + "devDependencies": { + "@types/passport-oauth2": "^1.8.0" + }, "engines": { "node": ">=18.0.0" } @@ -296,6 +299,16 @@ "@types/node": "*" } }, + "node_modules/@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", @@ -304,6 +317,18 @@ "@types/express": "*" } }, + "node_modules/@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -3325,6 +3350,15 @@ "@types/node": "*" } }, + "@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/passport": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", @@ -3333,6 +3367,17 @@ "@types/express": "*" } }, + "@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", diff --git a/package.json b/package.json index 4780b54..3027de7 100644 --- a/package.json +++ b/package.json @@ -43,5 +43,8 @@ }, "engines": { "node": ">=18.0.0" + }, + "devDependencies": { + "@types/passport-oauth2": "^1.8.0" } } diff --git a/src/authentication.ts b/src/authentication.ts index 8508e5d..967a99c 100644 --- a/src/authentication.ts +++ b/src/authentication.ts @@ -1,12 +1,16 @@ import passport from 'passport' -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { OAuth2Strategy } = require('passport-oauth') +import { Strategy as OAuth2Strategy } from 'passport-oauth2' import fetch from 'node-fetch' -import fs from 'fs' import { env } from './env' -import { UserProfile } from './types' import { Request, Response, NextFunction } from 'express' +/** + * Middleware to ensure user is authenticated. + * @param req Request object containing user information + * @param res Response object to redirect if not authenticated + * @param next Function to call after authentication check + * @returns Redirects to OAuth login if user is not authenticated + */ export function authenticate(req: Request, res: Response, next: NextFunction) { if (!req.user) { res.redirect(`/auth/${env.provider}`) @@ -15,86 +19,57 @@ export function authenticate(req: Request, res: Response, next: NextFunction) { } } -const usersDB: UserProfile[] = [] -const emptyUsersDB: string = JSON.stringify(usersDB) -if (!fs.existsSync(env.userDBpath) || fs.statSync(env.userDBpath).size < emptyUsersDB.length) { - fs.writeFileSync(env.userDBpath, emptyUsersDB) -} - -const users: UserProfile[] = JSON.parse(fs.readFileSync(env.userDBpath).toString()) - -passport.serializeUser((user, done) => { - //@ts-ignore - done(null, user.id) +// Store (only) access token in session +passport.serializeUser((user: any, done) => { + done(null, user.accessToken) }) -passport.deserializeUser((id, done) => { - const user = users.find(user => user.id === id) - done(null, user) +// On every request, validate access token +passport.deserializeUser(async (accessToken: string, done) => { + try { + const response = await fetch('https://api.intra.42.fr/v2/me', { + headers: { Authorization: `Bearer ${accessToken}` } + }); + + if (response.ok) { + done(null, { accessToken, isAuthenticated: true }); + } else { + done('Token expired', null); + } + } catch (error) { + done('Cannot verify token', null); + } }) -async function getProfile(accessToken: string, refreshToken: string): Promise { - try { - const response = await fetch('https://api.intra.42.fr/v2/me', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - const json = await response.json() - const profile: UserProfile = { - id: json.id, - login: json.login, - first_name: json.first_name, - displayname: json.displayname, - campusID: json.campus.length > 0 ? json.campus[0].id : 42, // set user's campus to first one listed in API call - campusName: json.campus.length > 0 ? json.campus[0].name : 'Paris', - timeZone: json.campus.length > 0 ? json.campus[0].time_zone : 'Europe/Paris', - accessToken, - refreshToken, - } - for (const i in json.campus_users) { - // get user's primary campus - if (json.campus_users[i].is_primary) { - for (const j in json.campus) { - // get primary campus name and store it in UserProfile (overwriting the one assigned above, which might not be primary) - if (json.campus[j].id === json.campus_users[i].campus_id) { - profile.campusName = json.campus[j].name - profile.timeZone = json.campus[j].time_zone - profile.campusID = json.campus_users[i].campus_id - break - } - } - break - } - } - return profile - } catch (err) { - return null - } -} +// OAuth2 strategy for initial authentication const opt = { - authorizationURL: env.authorizationURL, - tokenURL: env.tokenURL, - clientID: env.tokens.userAuth.UID, - clientSecret: env.tokens.userAuth.secret, - callbackURL: env.tokens.userAuth.callbackURL, - // passReqToCallback: true + authorizationURL: env.authorizationURL, + tokenURL: env.tokenURL, + clientID: env.tokens.userAuth.UID, + clientSecret: env.tokens.userAuth.secret, + callbackURL: env.tokens.userAuth.callbackURL, } -const client = new OAuth2Strategy(opt, async (accessToken: string, refreshToken: string, _profile: string, done: (err: string | null, user: UserProfile | null) => void) => { - // fires when user clicked allow - const newUser = await getProfile(accessToken, refreshToken) - if (!newUser) { - return done('cannot get user info', null) - } - const userIndex = users.findIndex(user => user.id === newUser.id) - if (userIndex < 0) { - users.push(newUser) - } else { - users[userIndex] = newUser - } - await fs.promises.writeFile(env.userDBpath, JSON.stringify(users)) - done(null, newUser) + +// Minimal strategy - only validate the access token +const client = new OAuth2Strategy(opt, async (accessToken: string, refreshToken: string, _profile: string, done: (err: string | null, user: any) => void) => { + try { + const response = await fetch('https://api.intra.42.fr/v2/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (response.ok) { + // Don't fetch user data - just pass the token + done(null, { accessToken, refreshToken }); + } else { + done('Invalid access token', null); + } + } catch (error) { + done('Authentication failed', null); + } }) + passport.use(env.provider, client) export { passport } From f0c52f9ec6a48fdce86ac2efe3d038d97096c24a Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 13:47:05 +0200 Subject: [PATCH 05/94] Removed unused database functions --- src/db.ts | 172 ------------------------------------------------------ 1 file changed, 172 deletions(-) delete mode 100644 src/db.ts diff --git a/src/db.ts b/src/db.ts deleted file mode 100644 index 33ac30e..0000000 --- a/src/db.ts +++ /dev/null @@ -1,172 +0,0 @@ -import fs from 'fs' -import { API } from '42-connector' -import { ApiProject, Project, ProjectSubscriber } from './types' -import { env, Campus, ProjectStatus, CampusName } from './env' -import { logCampus, log, msToHuman, nowISO } from './logger' -import * as StatsD from './statsd' - -const Api: API = new API(env.tokens.sync.UID, env.tokens.sync.secret, { - maxRequestPerSecond: env.tokens.sync.maxRequestPerSecond, - logging: env.logLevel >= 3, -}) - -export interface CampusDB { - name: CampusName - projects: Project[] - lastPull: number -} - -export const campusDBs: Record = {} as Record - -fs.mkdirSync(env.databaseRoot, { recursive: true }) -function setupCampusDB(campus: Campus) { - const campusDB: CampusDB = { - name: campus.name, - projects: [], - lastPull: 0, - } - - fs.mkdirSync(campus.databasePath, { recursive: true }) - if (!fs.existsSync(campus.projectUsersPath)) { - fs.writeFileSync(campus.projectUsersPath, '[]') - } - campusDB.projects = JSON.parse(fs.readFileSync(campus.projectUsersPath).toString()) - if (!fs.existsSync(campus.lastPullPath)) { - fs.writeFileSync(campus.lastPullPath, '0') - } - campusDB.lastPull = parseInt(fs.readFileSync(campus.lastPullPath).toString()) - campusDBs[campus.name] = campusDB -} - -for (const campus of Object.values(env.campuses)) { - setupCampusDB(campus) -} - -// Next time we use SQL -function findProjectUserByLogin(login: string, projectName: string): ProjectSubscriber | undefined { - for (const campus of Object.values(env.campuses)) { - const projects = campusDBs[campus.name].projects as Project[] - for (const project of projects) { - if (project.name !== projectName) { - continue - } - const user = project.users.find(x => x.login === login) - if (user) { - return user - } - } - } - return undefined -} - -function getUpdate(status: ProjectStatus, existingUser?: ProjectSubscriber): { new: boolean; lastChangeD: Date } { - if (!existingUser) { - return { new: true, lastChangeD: new Date() } - } - - if (status !== existingUser.status) { - return { new: true, lastChangeD: new Date() } - } - - const lastChangeD = new Date(existingUser.lastChangeD) - const isNew = Date.now() - lastChangeD.getTime() < env.userNewStatusThresholdDays * 24 * 60 * 60 * 1000 - return { new: isNew, lastChangeD: lastChangeD } -} - -// Intra's 'validated' key is sometimes wrong, therefore we use our own logic -function getStatus(x: Readonly): ProjectStatus { - let status: ProjectStatus = x['validated?'] ? 'finished' : x.status - - if (!env.projectStatuses.includes(x.status)) { - console.error(`Invalid status: ${x.status} on user ${x.user}`) - status = 'finished' - } - return status -} - -function toProjectSubscriber(x: Readonly, projectName: string): ProjectSubscriber | undefined { - try { - const status = getStatus(x) - const existing = findProjectUserByLogin(x.user.login, projectName) - const valid: ProjectSubscriber = { - login: x.user.login, - status, - staff: !!x.user['staff?'], - image_url: x.user.image.versions.medium, - ...getUpdate(status, existing), - } - return valid - } catch (e) { - console.error(e) - return undefined - } -} - -export async function getProjectSubscribers(campus: Campus, projectID: number, projectName: string): Promise { - const url = `/v2/projects/${projectID}/projects_users?filter[campus]=${campus.id}&page[size]=100` - const onPage = () => StatsD.increment('dbfetch', StatsD.strToTag('campus', campus.name)) - - const { ok, json: users }: { ok: boolean; json?: ApiProject[] } = await Api.getPaged(url, onPage) - if (!ok || !users) { - throw new Error('Could not get project subscribers') - } - return users.map(u => toProjectSubscriber(u, projectName)).filter(x => !!x) as ProjectSubscriber[] -} - -export async function writeAllProjectIds() { - const a = (await Api.getPaged(`/v2/projects`)) as { - ok: true - status: 200 - json: ({ id: string; slug: string; name: string } & Record)[] - } - const path = 'env/allProjectIDs.json' - const summary = a.json.map(x => ({ id: x.id, slug: x.slug, name: x.name })) - fs.writeFileSync(path, JSON.stringify(summary, null, 4)) - console.log('Project IDs written to', path) -} -// writeAllProjectIds() - -// @return number of users pulled -export async function saveAllProjectSubscribers(campus: Campus): Promise { - let usersPulled = 0 - const startPull = Date.now() - const newProjects: Project[] = [] - for (const [name, id] of Object.entries(env.projectIDs)) { - let item: Project - try { - item = { - name, - users: await getProjectSubscribers(campus, id, name), - } - } catch (e) { - return 0 - } - usersPulled += item.users.length - logCampus(2, campus.name, name, `total users: ${item.users.length}`) - newProjects.push(item) - } - campusDBs[campus.name].projects = newProjects - log(2, `Pull took ${msToHuman(Date.now() - startPull)}`) - - await fs.promises.writeFile(campus.projectUsersPath, JSON.stringify(newProjects)) - await fs.promises.writeFile(campus.lastPullPath, String(Date.now())) - campusDBs[campus.name].lastPull = parseInt((await fs.promises.readFile(campus.lastPullPath)).toString()) - return usersPulled -} - -// Sync all user statuses form all campuses if the env.pullTimeout for that campus has not been reached -export async function syncCampuses(): Promise { - const startPull = Date.now() - - log(1, 'starting pull') - for (const campus of Object.values(campusDBs)) { - const lastPullAgo = Date.now() - campus.lastPull - logCampus(2, campus.name, '', `last pull was on ${nowISO(campus.lastPull)}, ${(lastPullAgo / 1000 / 60).toFixed(0)} minutes ago`) - if (lastPullAgo < env.pullTimeout) { - logCampus(2, campus.name, '', `not pulling, timeout of ${env.pullTimeout / 1000 / 60} minutes not reached`) - continue - } - await saveAllProjectSubscribers(env.campuses[campus.name]) - } - log(1, `complete pull took ${msToHuman(Date.now() - startPull)}`) -} From d8c3cdcb537d2403a409a80fb8dec4e5eb9bfd35 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 13:51:07 +0200 Subject: [PATCH 06/94] Added transform, insert, and query functions using SQLite and Prisma --- package-lock.json | 66 ++++++++++-- package.json | 3 +- src/services.ts | 268 ++++++++++++++++++++++++++++++++++++++++++++++ src/transform.ts | 66 ++++++++++++ 4 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 src/services.ts create mode 100644 src/transform.ts diff --git a/package-lock.json b/package-lock.json index a3fc026..800b6d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@codam-coding-college/find-peers", "dependencies": { + "@prisma/client": "^6.14.0", "@types/compression": "^1.7.2", "@types/ejs": "3.1.0", "@types/express": "^4.17.17", @@ -36,7 +37,7 @@ "passport-oauth": "1.0.0", "path": "0.12.7", "request": "^2.88.2", - "typescript": "4.9.5" + "typescript": "^5.9.2" }, "devDependencies": { "@types/passport-oauth2": "^1.8.0" @@ -212,6 +213,28 @@ "node": ">= 8" } }, + "node_modules/@prisma/client": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.14.0.tgz", + "integrity": "sha512-8E/Nk3eL5g7RQIg/LUj1ICyDmhD053STjxrPxUtCRybs2s/2sOEcx9NpITuAOPn07HEpWBfhAVe1T/HYWXUPOw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/@types/body-parser": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -722,6 +745,19 @@ "url-parameter-append": "^1.0.5" } }, + "node_modules/42-connector/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2976,15 +3012,16 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/uid-safe": { @@ -3263,6 +3300,12 @@ "fastq": "^1.6.0" } }, + "@prisma/client": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.14.0.tgz", + "integrity": "sha512-8E/Nk3eL5g7RQIg/LUj1ICyDmhD053STjxrPxUtCRybs2s/2sOEcx9NpITuAOPn07HEpWBfhAVe1T/HYWXUPOw==", + "requires": {} + }, "@types/body-parser": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -3636,6 +3679,13 @@ "node-fetch": "^2.1.0", "typescript": "^4.2.4", "url-parameter-append": "^1.0.5" + }, + "dependencies": { + "typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==" + } } }, "accepts": { @@ -5280,9 +5330,9 @@ } }, "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==" + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==" }, "uid-safe": { "version": "2.1.5", diff --git a/package.json b/package.json index 3027de7..11e62fc 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint:fix": "eslint --fix src; prettier --write src" }, "dependencies": { + "@prisma/client": "^6.14.0", "@types/compression": "^1.7.2", "@types/ejs": "3.1.0", "@types/express": "^4.17.17", @@ -39,7 +40,7 @@ "passport-oauth": "1.0.0", "path": "0.12.7", "request": "^2.88.2", - "typescript": "4.9.5" + "typescript": "^5.9.2" }, "engines": { "node": ">=18.0.0" diff --git a/src/services.ts b/src/services.ts new file mode 100644 index 0000000..68d8bbc --- /dev/null +++ b/src/services.ts @@ -0,0 +1,268 @@ +import { PrismaClient, User, Project, Campus, ProjectUser } from '@prisma/client' + +const prisma = new PrismaClient(); + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) + return error.message; + return String(error); +} + +export class DatabaseService { + + /*************************************************************************\ + * Query Methods * + \*************************************************************************/ + + + /** + * Retrieve Users based on the given campus. + * @param status The campus to filter on. + * @returns The list of filtered users. + */ + static async getUsersByCampus(campus_id: number): Promise { + return prisma.user.findMany({ + where: { primary_campus_id: campus_id } + }); + } + + /** + * Retrieve Project Users based on the given status. + * @param status The project status to filter on. + * @returns The list of filtered project users. + */ + static async getProjectUsersByStatus(status: string): Promise { + return prisma.projectUser.findMany({ + where: { status: status } + }); + } + + /** + * Retrieve user IDs that are missing from the user table. + * @param projectUsers The list of project users to check against the database. + * @returns The list of missing user IDs. + */ + static async getMissingUserIds(projectUsers: any[]): Promise { + const userIds = [...new Set(projectUsers.map(pu => pu.user_id))]; + + const existingUsers = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true } + }); + + const existingUserIds = new Set(existingUsers.map(u => u.id)); + return userIds.filter(id => !existingUserIds.has(id)); + } + + /** + * Retrieve projects that are missing from the project table. + * @param projectUsers The list of project users to check against the database. + * @returns The list of missing project IDs. + */ + static async getMissingProjects(projectUsers: any[]): Promise { + const projectIds = [...new Set(projectUsers.map(pu => pu.project_id))]; + const existingProjects = await prisma.project.findMany({ + where: { id: { in: projectIds } }, + select: { + id: true, + name: true, + slug: true + } + }); + const existingProjectIds = new Set(existingProjects.map(p => p.id)); + const missingProjectIds = projectIds.filter(id => !existingProjectIds.has(id)); + const projectDataMap = new Map(); + projectUsers.forEach(pu => { + if (!projectDataMap.has(pu.project_id)) { + projectDataMap.set(pu.project_id, { + id: pu.project_id, + name: pu.name, + slug: pu.slug + }); + } + }); + return missingProjectIds.map(id => projectDataMap.get(id)); + } + + /** + * Retrieve campus IDs that are missing from the campus table. + * @param users The list of users to check against the database. + * @returns The list of missing campus IDs. + */ + static async getMissingCampusIds(users: any[]): Promise { + const campusIds = [...new Set(users.map(u => u.primary_campus_id))]; + + const existingCampus = await prisma.campus.findMany({ + where: { id: { in: campusIds } }, + select: { id: true } + }); + + const existingCampusIds = new Set(existingCampus.map(c => c.id)); + return campusIds.filter(id => !existingCampusIds.has(id)); + } + + + /*************************************************************************\ + * Insert Methods * + \*************************************************************************/ + + + /** + * Inserts a project user into the database. + * @param {ProjectUser} projectUser - The project user data to insert. + * @returns {Promise} - The ID of the inserted project user. + */ + static async insertProjectUser(projectUser: ProjectUser): Promise { + try { + return prisma.projectUser.upsert({ + where: { + project_id_user_id: { + user_id: projectUser.user_id, + project_id: projectUser.project_id + } + }, + update: projectUser, + create: projectUser + }); + } catch (error) { + throw new Error(`Failed to insert project user ${projectUser.user_id}: ${getErrorMessage(error)}`); + } + } + + /** + * Inserts multiple project users into the database. + * @param projectUsers - The list of project user data to insert. + * @returns {Promise} - Resolves when all project users are inserted. + */ + static async insertManyProjectUsers(projectUsers: ProjectUser[]): Promise { + try { + const insert = projectUsers.map(projectUser => + prisma.projectUser.upsert({ + where: { + project_id_user_id: { + user_id: projectUser.user_id, + project_id: projectUser.project_id + } + }, + update: projectUser, + create: projectUser + }) + ); + await prisma.$transaction(insert); + } catch (error) { + throw new Error(`Failed to insert project users: ${getErrorMessage(error)}`); + } + } + + /** + * Inserts a user into the database. + * @param {User} user - The user data to insert. + * @returns {Promise} - Resolves when the user is inserted. + */ + static async insertUser(user: User): Promise { + try { + return prisma.user.upsert({ + where: { id: user.id }, + update: user, + create: user + }); + } catch (error) { + throw new Error(`Failed to insert user ${user.id}: ${getErrorMessage(error)}`); + } + } + + /** + * Inserts multiple users into the database. + * @param {User} users - The list of user data to insert. + * @returns {Promise} - Resolves when the user is inserted. + */ + static async insertManyUsers(users: User[]): Promise { + try { + const insert = users.map(user => + prisma.user.upsert({ + where: { id: user.id }, + update: user, + create: user + }) + ); + await prisma.$transaction(insert); + } catch (error) { + throw new Error(`Failed to insert users: ${getErrorMessage(error)}`); + } + } + + /** + * Inserts a campus into the database. + * @param campus - The campus data to insert. + * @returns {Promise} - The ID of the inserted campus. + */ + static async insertCampus(campus: Campus): Promise { + try { + return prisma.campus.upsert({ + where: { id: campus.id }, + update: campus, + create: campus + }); + } catch (error) { + throw new Error(`Failed to insert user ${campus.id}: ${getErrorMessage(error)}`); + } + } + + /** + * Inserts multiple campuses into the database. + * @param campuses - The list of campus data to insert. + * @returns {Promise} - Resolves when all campuses are inserted. + */ + static async insertManyCampuses(campuses: Campus[]): Promise { + try { + const insert = campuses.map(campus => + prisma.campus.upsert({ + where: { id: campus.id }, + update: campus, + create: campus + }) + ); + await prisma.$transaction(insert); + } catch (error) { + throw new Error(`Failed to insert campuses: ${getErrorMessage(error)}`); + } + } + + /** + * Inserts a project into the database. + * @param project - The project data to insert. + * @returns {Promise} - Resolves when all projects are inserted. + */ + static async insertProject(project: Project): Promise { + try { + return prisma.project.upsert({ + where: { id: project.id }, + update: project, + create: project + }); + } + catch (error) { + throw new Error(`Failed to insert project ${project.id}: ${getErrorMessage(error)}`); + } + } + + /** + * Inserts multiple projects into the database. + * @param projects - The list of project data to insert. + * @returns {Promise} - Resolves when all projects are inserted. + */ + static async insertManyProjects(projects: Project[]): Promise { + try { + const insert = projects.map(project => + prisma.project.upsert({ + where: { id: project.id }, + update: project, + create: project + }) + ); + await prisma.$transaction(insert); + } catch (error) { + throw new Error(`Failed to insert projects: ${getErrorMessage(error)}`); + } + } +} diff --git a/src/transform.ts b/src/transform.ts new file mode 100644 index 0000000..d96ffe0 --- /dev/null +++ b/src/transform.ts @@ -0,0 +1,66 @@ +import { User, Project, Campus, ProjectUser } from '@prisma/client' + +/** + * Transforms 42Api /v2/projects_users data to Database data. + * @param apiProjectUser Fetched data from the 42Api + * @returns ProjectUser object for the database + */ +export function transformApiProjectUserToDb(apiProjectUser: any): ProjectUser { + return { + project_id: apiProjectUser.project.id, + user_id: apiProjectUser.user.id, + created_at: apiProjectUser.created_at, + updated_at: apiProjectUser.updated_at, + validated_at: apiProjectUser.marked_at || null, + status: apiProjectUser.status + }; +} + +/** + * Transforms 42Api /v2/users data to Database data. + * @param apiUser Fetched data from the 42Api + * @returns User object for the database + */ +export function transformApiUserToDb(apiUser: any): User { + let primaryCampus; + if (apiUser.campus_users && apiUser.campus_users.length > 1) { + // get campus where campus_users[i].primary is true + primaryCampus = apiUser.campus_users.find((cu: any) => cu.primary); + } + else if (apiUser.campus_users && apiUser.campus_users.length === 1) { + primaryCampus = apiUser.campus_users[0]; + } + + return { + id: apiUser.id, + login: apiUser.login, + primary_campus_id: primaryCampus ? primaryCampus.campus_id : null, + image_url: apiUser.image?.versions?.medium || null, + anonymize_date: apiUser.anonymize_date || null + }; +} + +/** + * Transforms 42Api /v2/campus data to Database data. + * @param apiCampus Fetched data from the 42Api + * @returns Campus object for the database + */ +export function transformApiCampusToDb(apiCampus: any): Campus { + return { + id: apiCampus.id, + name: apiCampus.name || '' + }; +} + +/** + * Transforms 42Api /v2/projects_users data to Database data. + * @param apiProjectUser Fetched data from the 42Api + * @returns Project object for the database + */ +export function transformApiProjectToDb(apiProjectUser: any): Project { + return { + id: apiProjectUser.id, + slug: apiProjectUser.slug, + name: apiProjectUser.name || '', + }; +} From 7f508ac9e5dae7d58eb35b51a7ab6287f1d1641e Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 13:52:00 +0200 Subject: [PATCH 07/94] Removed unused types --- src/types.ts | 114 --------------------------------------------------- 1 file changed, 114 deletions(-) delete mode 100644 src/types.ts diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index cdba216..0000000 --- a/src/types.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { ProjectStatus } from './env' - -export interface ApiProject { - id: number - occurrence: number - final_mark: number - status: ProjectStatus - 'validated?': boolean - current_team_id: number - project: { - id: number - name: string - slug: string - parent_id?: unknown - } - cursus_ids: number[] - marked_at: Date - marked: boolean - retriable_at: Date - created_at: Date - updated_at: Date - user: { - id: number - email: string - login: string - first_name: string - last_name: string - usual_full_name: string - 'usual_first_name?': unknown - url: string - phone: string - displayname: string - kind: string - image_url: string - image: { - link: string - versions: { - large: string - medium: string - small: string - micro: string - } - } - new_image_url: string - 'staff?': boolean - correction_point: number - pool_month: string - pool_year: string - 'location?': unknown - wallet: number - anonymize_date: Date - data_erasure_date: Date - created_at: Date - updated_at: Date - 'alumnized_at?': unknown - 'alumni?': boolean - 'active?': boolean - } - teams: { - id: number - name: string - url: string - final_mark: number - project_id: number - created_at: Date - updated_at: Date - status: string - 'terminating_at?': unknown - users: { - id: number - login: string - url: string - leader: boolean - occurrence: number - validated: boolean - projects_user_id: number - }[] - 'locked?': boolean - 'validated?': boolean - 'closed?': boolean - repo_url: string - repo_uuid: string - locked_at: Date - closed_at: Date - project_session_id: number - project_gitlab_path: string - }[] -} - -export interface ProjectSubscriber { - login: string - status: ProjectStatus - staff: boolean - image_url: string - lastChangeD: Date | string - new: boolean -} - -export interface Project { - name: string - users: ProjectSubscriber[] -} - -export interface UserProfile { - id: number - login: string - first_name: string - displayname: string - accessToken: string - refreshToken: string - campusID: number - campusName: string - timeZone: string -} From 9682763245f0bbfbc8aa3701f92485ccedb0a232 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 13:52:53 +0200 Subject: [PATCH 08/94] Added functions for fetching data from the 42Api using fast42 --- src/wrapper.ts | 199 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 src/wrapper.ts diff --git a/src/wrapper.ts b/src/wrapper.ts new file mode 100644 index 0000000..b062283 --- /dev/null +++ b/src/wrapper.ts @@ -0,0 +1,199 @@ +import Fast42 from "@codam/fast42"; +import { NODE_ENV, DEV_DAYS_LIMIT } from "./env"; + +/** + * Fetch all items from all pages of a Fast42 API endpoint. + * @usage const codamStudents = await fetchMultiple42ApiPages(api, '/v2/campus/14/users'); + * @param api A Fast42 instance + * @param path The API path to fetch + * @param params Optional query parameters for the API request + * @returns A promise that resolves to an array containing all items from all pages of the API responses + */ +export const fetchMultiple42ApiPages = async function(api: Fast42, path: string, params: { [key: string]: string } = {}): Promise { + return new Promise(async (resolve, reject) => { + try { + const pages = await api.getAllPages(path, params); + + let i = 0; + const pageItems = await Promise.all(pages.map(async (page) => { + let p = null; + pageFetch: while (!p) { + p = await page; + if (p.status == 429) { + console.error('Intra API rate limit exceeded, let\'s wait a bit...'); + const waitFor = parseInt(p.headers.get('Retry-After') ?? '1'); + console.log(`Waiting ${waitFor} seconds...`); + await new Promise((resolve) => setTimeout(resolve, waitFor * 1000 + Math.random() * 1000)); + p = null; + continue pageFetch; + } + if (!p.ok) { + throw new Error(`Intra API error: ${p.status} ${p.statusText} on ${p.url}`); + } + } + if (p.ok) { + const data = await p.json(); + console.debug(`Fetched page ${++i} of ${pages.length} on ${path}...`); + return data; + } + })); + return resolve(pageItems.flat()); + } + catch (err) { + return reject(err); + } + }); +}; + +/** + * Fetch all items from all pages of a Fast42 API endpoint, with a callback function for each page fetched. + * Useful for larger datasets that may not fit in memory. + * @usage const codamStudents = await fetchMultiple42ApiPages(api, '/v2/campus/14/users'); + * @param api A Fast42 instance + * @param path The API path to fetch + * @param params Optional query parameters for the API request + * @param callback A callback function to call for each page fetched + * @returns A promise that resolves to an array containing all items from all pages of the API responses + */ +export const fetchMultiple42ApiPagesCallback = async function(api: Fast42, path: string, params: { [key: string]: string } = {}, callback: (data: any, xPage: number, xTotal: number) => void): Promise { + return new Promise(async (resolve, reject) => { + try { + const pages = await api.getAllPages(path, params); + + let i = 0; + for (const page of pages) { + let p = null; + pageFetch: while (!p) { + p = await page; + if (!p) { + console.log('Retrying page fetch...'); + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue pageFetch; + } + if (p.status == 429) { + console.error('Intra API rate limit exceeded, let\'s wait a bit...'); + const waitFor = parseInt(p.headers.get('Retry-After') ?? '1'); + console.log(`Waiting ${waitFor} seconds...`); + await new Promise((resolve) => setTimeout(resolve, waitFor * 1000 + Math.random() * 1000)); + p = null; + continue pageFetch; + } + if (!p.ok) { + throw new Error(`Intra API error: ${p.status} ${p.statusText} on ${p.url}`); + } + } + if (p.ok) { + const xPage = parseInt(p.headers.get('X-Page') ?? '1'); + const xTotal = parseInt(p.headers.get('X-Total') ?? '1'); + const data = await p.json(); + console.debug(`Fetched page ${++i} of ${pages.length} on ${path}...`); + callback(data, xPage, xTotal); + } + } + return resolve(); + } + catch (err) { + return reject(err); + } + }); +}; + +/** + * Fetch a single page of a Fast42 API endpoint. + * @param api A Fast42 instance + * @param path The API path to fetch + * @param params Optional query parameters for the API request + * @returns A promise that resolves to the JSON data from the API response + */ +export const fetchSingle42ApiPage = async function(api: Fast42, path: string, params: { [key: string]: string } = {}): Promise { + return new Promise(async (resolve, reject) => { + try { + retry: while (true) { + const page = await api.get(path, params); + + if (page.status == 429) { + console.error('Intra API rate limit exceeded, let\'s wait a bit...'); + const waitFor = parseInt(page.headers.get('Retry-After') ?? '1'); + console.log(`Waiting ${waitFor} seconds...`); + await new Promise((resolve) => setTimeout(resolve, waitFor * 1000 + Math.random() * 1000)); + continue retry; + } + if (page.ok) { + const data = await page.json(); + return resolve(data); + } + else { + reject(`Intra API error: ${page.status} ${page.statusText} on ${page.url}`); + break; + } + } + } + catch (err) { + return reject(err); + } + }); +}; + +/** + * Synchronize data with the Intra API. + * @param api A Fast42 instance + * @param syncDate The current date + * @param lastSyncDate The date of the last synchronization + * @param path The API path to fetch + * @param params The query parameters for the API request + */ +export const syncData = async function(api: Fast42, syncDate: Date, lastSyncDate: Date | undefined, path: string, params: any): Promise { + // In development mode we do not want to be stuck fetching too much data, + // so we impose a limit based on the DEV_DAYS_LIMIT environment variable. + // + // The only case in which we do not want to do this is the users endpoint, + // for which we always fetch all data + if (lastSyncDate === undefined && NODE_ENV == "development" && !path.includes('/users')) { + lastSyncDate = new Date(syncDate.getTime() - DEV_DAYS_LIMIT * 24 * 60 * 60 * 1000); + } + + if (lastSyncDate !== undefined) { + params['range[updated_at]'] = `${lastSyncDate.toISOString()},${syncDate.toISOString()}`; + console.log(`Fetching data from Intra API updated on path ${path} since ${lastSyncDate.toISOString()}...`); + } + else { + console.log(`Fetching all data from Intra API on path ${path}...`); + } + + return await fetchMultiple42ApiPages(api, path, params); +}; + +/** + * Synchronize data with the Intra API. + * @param api A Fast42 instance + * @param syncDate The current date + * @param lastSyncDate The date of the last synchronization + * @param path The API path to fetch + * @param params The query parameters for the API request + * @param callback A callback function to handle the synchronized data + */ +export const syncDataCB = async function(api: Fast42, syncDate: Date, lastSyncDate: Date | undefined, path: string, params: any, callback: (data: any) => void): Promise { + // In development mode we do not want to be stuck fetching too much data, + // so we impose a limit based on the DEV_DAYS_LIMIT environment variable. + if (lastSyncDate === undefined && NODE_ENV == "development") { + lastSyncDate = new Date(syncDate.getTime() - DEV_DAYS_LIMIT * 24 * 60 * 60 * 1000); + } + + if (lastSyncDate !== undefined) { + if (!path.includes('locations')) { + params['range[updated_at]'] = `${lastSyncDate.toISOString()},${syncDate.toISOString()}`; + } + else { + // Decrease lastSyncDate by 72 hours + // Locations do not have the updated_at field, so we use the begin_at field instead + lastSyncDate = new Date(lastSyncDate.getTime() - 72 * 60 * 60 * 1000); + params['range[begin_at]'] = `${lastSyncDate.toISOString()},${syncDate.toISOString()}`; + } + console.log(`Fetching data from Intra API updated on path ${path} since ${lastSyncDate.toISOString()}...`); + } + else { + console.log(`Fetching all data from Intra API on path ${path}...`); + } + + await fetchMultiple42ApiPagesCallback(api, path, params, callback); +} From 69b72a1b55b0fef7b04149dd924f243a2b5461ce Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 13:53:49 +0200 Subject: [PATCH 09/94] Installed fast42 --- package-lock.json | 158 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 159 insertions(+) diff --git a/package-lock.json b/package-lock.json index 800b6d6..94b929c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@codam-coding-college/find-peers", "dependencies": { + "@codam/fast42": "^2.1.6", "@prisma/client": "^6.14.0", "@types/compression": "^1.7.2", "@types/ejs": "3.1.0", @@ -46,6 +47,18 @@ "node": ">=18.0.0" } }, + "node_modules/@codam/fast42": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@codam/fast42/-/fast42-2.1.6.tgz", + "integrity": "sha512-aMoGdlzr9FL9snws4gonbajBYQRvGFB3HIYg1hZv9pMeFf+5dDk5wgfTl1aMAYFMnTBNZJTGo33gaYpWjQpuAg==", + "license": "ISC", + "dependencies": { + "@sergiiivzhenko/bottleneck": "2.19.7", + "node-cache": "^5.1.2", + "node-fetch": "^2.6.2", + "redis": "^3.1.2" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -235,6 +248,12 @@ } } }, + "node_modules/@sergiiivzhenko/bottleneck": { + "version": "2.19.7", + "resolved": "https://registry.npmjs.org/@sergiiivzhenko/bottleneck/-/bottleneck-2.19.7.tgz", + "integrity": "sha512-Wm06FyMoTsbVTO1CPQ5CubsB9x4uTSpi0C3W9TMYKOwcIEuXEtR6lRxtmpQe42Eic9assvGt+Po5rXsDPjzAMA==", + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -989,6 +1008,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1150,6 +1178,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -2287,6 +2324,18 @@ "node": ">= 0.6" } }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -2660,6 +2709,52 @@ "node": ">= 0.8" } }, + "node_modules/redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "license": "MIT", + "dependencies": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-redis" + } + }, + "node_modules/redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==", + "license": "MIT" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/request": { "version": "2.88.2", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", @@ -3188,6 +3283,17 @@ } }, "dependencies": { + "@codam/fast42": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@codam/fast42/-/fast42-2.1.6.tgz", + "integrity": "sha512-aMoGdlzr9FL9snws4gonbajBYQRvGFB3HIYg1hZv9pMeFf+5dDk5wgfTl1aMAYFMnTBNZJTGo33gaYpWjQpuAg==", + "requires": { + "@sergiiivzhenko/bottleneck": "2.19.7", + "node-cache": "^5.1.2", + "node-fetch": "^2.6.2", + "redis": "^3.1.2" + } + }, "@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -3306,6 +3412,11 @@ "integrity": "sha512-8E/Nk3eL5g7RQIg/LUj1ICyDmhD053STjxrPxUtCRybs2s/2sOEcx9NpITuAOPn07HEpWBfhAVe1T/HYWXUPOw==", "requires": {} }, + "@sergiiivzhenko/bottleneck": { + "version": "2.19.7", + "resolved": "https://registry.npmjs.org/@sergiiivzhenko/bottleneck/-/bottleneck-2.19.7.tgz", + "integrity": "sha512-Wm06FyMoTsbVTO1CPQ5CubsB9x4uTSpi0C3W9TMYKOwcIEuXEtR6lRxtmpQe42Eic9assvGt+Po5rXsDPjzAMA==" + }, "@types/body-parser": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -3865,6 +3976,11 @@ "supports-color": "^7.1.0" } }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3994,6 +4110,11 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, + "denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==" + }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -4844,6 +4965,14 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, + "node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "requires": { + "clone": "2.x" + } + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -5084,6 +5213,35 @@ "unpipe": "1.0.0" } }, + "redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "requires": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + } + }, + "redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "requires": { + "redis-errors": "^1.0.0" + } + }, "request": { "version": "2.88.2", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", diff --git a/package.json b/package.json index 11e62fc..9be1755 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint:fix": "eslint --fix src; prettier --write src" }, "dependencies": { + "@codam/fast42": "^2.1.6", "@prisma/client": "^6.14.0", "@types/compression": "^1.7.2", "@types/ejs": "3.1.0", From bb2a31303611405a18a5f32d7fded96283247417 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 13:54:43 +0200 Subject: [PATCH 10/94] Added variables to limit fetching during development --- src/env.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/env.ts b/src/env.ts index 42ecdc4..6f3b758 100644 --- a/src/env.ts +++ b/src/env.ts @@ -4,6 +4,9 @@ import campusIDs from '../env/campusIDs.json' import projectIDs from '../env/projectIDs.json' import { assertEnvInt, assertEnvStr, mapObject } from './util' +export const DEV_DAYS_LIMIT: number = process.env['DEV_DAYS_LIMIT'] ? parseInt(process.env['DEV_DAYS_LIMIT'] as string) : 365; +export const NODE_ENV = process.env['NODE_ENV'] || 'development'; + export type CampusID = (typeof campusIDs)[keyof typeof campusIDs] export type CampusName = keyof typeof campusIDs From b1f89139e378b4435dbcfb24a5cac299c60ed354 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 13:58:31 +0200 Subject: [PATCH 11/94] Added functions to synchronize the database with Intra --- src/sync.ts | 234 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 src/sync.ts diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..c75c897 --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,234 @@ +import fs from 'fs'; +import Fast42 from "@codam/fast42"; +import { env } from "./env"; +import { syncData, syncDataCB } from "./wrapper"; +import { transformApiCampusToDb, transformApiProjectUserToDb, transformApiUserToDb, transformApiProjectToDb } from './transform'; +import { DatabaseService } from './services'; + + + +const fast42Api = new Fast42( + [ + { + client_id: env.tokens.sync.UID, + client_secret: env.tokens.sync.secret, + }, + ] +); + +/** + * Initialize Fast42 API. + */ +let fast42Initialized = false; +async function initializeFast42() { + try { + await fast42Api.init(); + fast42Initialized = true; + console.log('Fast42 initialized successfully'); + } catch (error) { + console.error('Failed to initialize Fast42:', error); + } +} + +/** + * Synchronize with 42API. + * This function fetches project users, users, campuses, and projects from the Fast42 API and saves them to the database. + * It also saves the last synchronization timestamp to a file. + * @returns A promise that resolves when the synchronization is complete + * @throws Will throw an error if the synchronization fails + */ +export const syncWithIntra = async function(): Promise { + if (!fast42Initialized) { + console.log('Waiting for Fast42 to initialize...'); + await initializeFast42(); + } + const now = new Date(); + + console.info(`Starting Intra synchronization at ${now.toISOString()}...`); + try { + const lastSync = await getLastSyncTimestamp(); + + await syncProjectUsersCB(fast42Api, lastSync); + await saveSyncTimestamp(now); + + console.info(`Intra synchronization completed at ${new Date().toISOString()}.`); + } + catch (err) { + console.error('Failed to synchronize with Intra:', err); + console.log('Future synchronization attempts will start from the last successful sync timestamp, so no data should be missing.'); + } +} + +/** + * Sync project users with the Fast42 API using a callback.\ + * -If a new project is found, create project using projectUser data.\ + * -If a new user is found, create user using users/${user_id}.\ + * -If a new campus is found, sync campus data using the user's campus_id. + * @param fast42Api The Fast42 API instance to use for fetching project users + * @param lastPullDate The date of the last synchronization + * @returns A promise that resolves when the synchronization is complete + */ +async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date): Promise { + return new Promise((resolve, reject) => { + const callback = async (projectUsers: any[]) => { + try { + if (projectUsers.length === 0) { + console.log('No project users found to sync.'); + return; + } + console.log(`Processing batch of ${projectUsers.length} project users...`); + + // If any project doesn't exist in the 'project' table, create an entry in 'project' table. + const missingProjects = await DatabaseService.getMissingProjects(projectUsers); + if (missingProjects.length > 0) { + console.log(`Found ${missingProjects.length} missing projects, syncing...`); + await syncProjects(missingProjects); + } + + // If any projectUser doesn't exist in the 'user' table, create an entry in 'user' table. + const missingUserIds = await DatabaseService.getMissingUserIds(projectUsers); + if (missingUserIds.length > 0) { + console.log(`Found ${missingUserIds.length} missing users, syncing...`); + await syncUsersCB(fast42Api, lastPullDate, missingUserIds); + } + + const dbProjectUsers = projectUsers.map(transformApiProjectUserToDb); + await DatabaseService.insertManyProjectUsers(dbProjectUsers); + } catch (error) { + console.error('Failed to process project users batch:', error); + throw error; + } + }; + + syncDataCB(fast42Api, new Date(), lastPullDate, '/projects_users', + { 'page[size]': '100' }, callback) + .then(() => { + console.log('Finished syncing project users with callback method.'); + resolve(); + }) + .catch((error) => { + console.error('Failed to sync project users with callback:', error); + reject(error); + }); + }); +} + +/** + * Sync users with the Fast42 API using a callback. + * @param fast42Api The Fast42 API instance to use for fetching project users + * @param lastPullDate The date of the last synchronization + * @returns A promise that resolves when the synchronization is complete + */ +async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date, userIds: number[]): Promise { + const promises = userIds.map(userId => { + return new Promise((resolve, reject) => { + const callback = async (users: any[]) => { + try { + if (users.length == 0) { + console.log('No users found to sync.'); + return; + } + console.log(`Processing batch of ${users.length} users...`); + + // If any projectUser doesn't exist in the 'user' table, create an entry in 'user' table. + const missingCampusIds = await DatabaseService.getMissingCampusIds(users); + if (missingCampusIds.length > 0) { + console.log(`Found ${missingCampusIds.length} missing campuses, syncing...`); + await syncCampus(fast42Api, lastPullDate, missingCampusIds); + } + + const dbUsers = users.map(transformApiUserToDb); + await DatabaseService.insertManyUsers(dbUsers); + } catch (error) { + console.error('Failed to process project users batch:', error); + throw error; + } + }; + + // Fetch user for each userId + syncDataCB(fast42Api, new Date(), lastPullDate, `/users/${userId}`, + { 'page[size]': '100' }, callback) + .then(() => { + console.log('Finished syncing users with callback method.'); + resolve(undefined); + }) + .catch((error) => { + console.error('Failed to sync users with callback:', error); + reject(error); + }); + }); + }); + await Promise.all(promises); + console.log('Syncing Users completed.'); +} + +/** + * Sync campuses with the Fast42 API. + * @param fast42Api The Fast42 API instance to use for fetching campuses + * @param lastPullDate The date of the last synchronization + * @param campusIds The IDs of the campuses to sync + * @returns A promise that resolves when the synchronization is complete + */ +async function syncCampus(fast42Api: Fast42, lastPullDate: Date, campusIds: number[]): Promise { + // Fetch all campuses in one API call using filter[id] + try { + const campuses = await syncData(fast42Api, new Date(), lastPullDate, '/campus', + { 'page[size]': '100', 'filter[id]': campusIds.join(',') } + ); + + if (!Array.isArray(campuses) || campuses.length === 0) { + console.log(`No campuses found with ids: ${campusIds.join(", ")}.`); + return; + } + + console.log(`Processing ${campuses.length} campuses...`); + const dbCampuses = campuses.map(transformApiCampusToDb); + await DatabaseService.insertManyCampuses(dbCampuses); + console.log('Finished syncing campuses.'); + } catch (error) { + console.error('Failed to sync campuses:', error); + throw error; + } +} + +/** + * Sync projects with the Fast42 API. + * @param projects The list of projects to sync + * @returns A promise that resolves when the synchronization is complete + */ +async function syncProjects(projects: any[]): Promise { + try { + console.log(`Processing ${projects.length} projects...`); + const dbProjects = projects.map(transformApiProjectToDb); + await DatabaseService.insertManyProjects(dbProjects); + console.log('Finished syncing projects.'); + } catch (error) { + console.error('Failed to sync projects:', error); + throw error; + } +} + +/** + * Get the last synchronization timestamp. + */ +export const getLastSyncTimestamp = async function(): Promise { + return new Promise((resolve) => { + fs.readFile('.sync-timestamp', 'utf8', (err, data) => { + if (err) { + return resolve(new Date(0)); + } + return resolve(new Date(parseInt(data))); + }); + }); +} + +/** + * Save the synchronization timestamp. + * @param timestamp The timestamp to save + */ +const saveSyncTimestamp = async function(timestamp: Date): Promise { + console.log('Saving timestamp of synchronization to ./.sync-timestamp...'); + // Save to current folder in .sync-timestamp file + fs.writeFileSync('.sync-timestamp', timestamp.getTime().toString()); + console.log('Timestamp saved to ./.sync-timestamp'); +} From 66257132fd9d30e2b92c295cd4d5afd61c311660 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 14:05:48 +0200 Subject: [PATCH 12/94] IDs will now be stored in the SQLite database --- env/allProjectIDs.json | 7627 ---------------------------------------- env/campusIDs.json | 51 - env/projectIDs.json | 174 - 3 files changed, 7852 deletions(-) delete mode 100644 env/allProjectIDs.json delete mode 100644 env/campusIDs.json delete mode 100644 env/projectIDs.json diff --git a/env/allProjectIDs.json b/env/allProjectIDs.json deleted file mode 100644 index 3db1c09..0000000 --- a/env/allProjectIDs.json +++ /dev/null @@ -1,7627 +0,0 @@ -[ - { - "id": 2504, - "slug": "codam-startup-internship-codam-startup-internship-contract-upload", - "name": "Codam Startup Internship - Contract Upload" - }, - { - "id": 2246, - "slug": "hive-startup-internship-hive-startup-internship-contract-upload", - "name": "Hive Startup Internship - Contract Upload" - }, - { - "id": 2240, - "slug": "hive-internship-hive-internship-contract-upload", - "name": "Hive Internship - Contract Upload" - }, - { - "id": 2066, - "slug": "rushes-libunit", - "name": "Libunit" - }, - { - "id": 1874, - "slug": "apprentissage-1-an-contract-upload", - "name": "Contract Upload " - }, - { - "id": 1866, - "slug": "apprentissage-2-ans-2eme-annee-contract-upload", - "name": "Contract Upload" - }, - { - "id": 1858, - "slug": "apprentissage-2-ans-1ere-annee-contract-upload", - "name": "Contract Upload" - }, - { - "id": 1800, - "slug": "piscine-java-day-00", - "name": "Day 00" - }, - { - "id": 1787, - "slug": "piscine-python-data-science-day-00", - "name": "Day 00" - }, - { - "id": 1710, - "slug": "apcsp-prep-apcsp-programming", - "name": "APCSP-Programming" - }, - { - "id": 1663, - "slug": "startup-internship-startup-internship-contract-upload", - "name": "Startup Internship - Contract Upload" - }, - { - "id": 1657, - "slug": "part_time-ii-part_time-ii-contract-upload", - "name": "Part_Time II - Contract Upload" - }, - { - "id": 1651, - "slug": "part_time-i-part_time-i-contract-upload", - "name": "Part_Time I - Contract Upload" - }, - { - "id": 1645, - "slug": "work-experience-ii-work-experience-ii-contract-upload", - "name": "Work Experience II - Contract Upload" - }, - { - "id": 1640, - "slug": "work-experience-i-work-experience-i-contract-upload", - "name": "Work Experience I - Contract Upload" - }, - { - "id": 1636, - "slug": "open-project-open-project-define-your-subject", - "name": "Open Project - Define your Subject" - }, - { - "id": 1620, - "slug": "42cursus-piscine-python-django-day-00", - "name": "Day 00" - }, - { - "id": 1608, - "slug": "42cursus-piscine-ruby-on-rails-day-00", - "name": "Day 00" - }, - { - "id": 1596, - "slug": "42cursus-piscine-swift-ios-day-00", - "name": "Day 00" - }, - { - "id": 1584, - "slug": "42cursus-piscine-php-symfony-day-00", - "name": "Day 00" - }, - { - "id": 1572, - "slug": "42cursus-piscine-ocaml-day-00", - "name": "Day 00" - }, - { - "id": 1560, - "slug": "42cursus-piscine-unity-day-00", - "name": "Day 00" - }, - { - "id": 1368, - "slug": "data-structures-in-python-part-1-linked-lists", - "name": "Part 1: Linked Lists" - }, - { - "id": 1367, - "slug": "java-runestone-academy-ap-java", - "name": "Runestone Academy - AP Java" - }, - { - "id": 1354, - "slug": "pygame-intro-to-oop", - "name": "Intro to OOP" - }, - { - "id": 1296, - "slug": "api-s-with-node-js-api-creation", - "name": "API Creation" - }, - { - "id": 1286, - "slug": "machine-learning-using-python-ml_01", - "name": "ML_01" - }, - { - "id": 1240, - "slug": "go-programming-go-00", - "name": "Go 00" - }, - { - "id": 1221, - "slug": "javascript-and-graphics-in-p5js-p5js-00", - "name": "p5js-00" - }, - { - "id": 1192, - "slug": "data-mining-the-49ers-web-scraping-with-beautiful-soup", - "name": "Web Scraping with Beautiful Soup" - }, - { - "id": 1148, - "slug": "piscine-php-symfony-day-00", - "name": "Day 00" - }, - { - "id": 1131, - "slug": "javascript-web01-html_css", - "name": "Web01 - HTML_CSS" - }, - { - "id": 1110, - "slug": "hack-your-own-adventure-map-your-own-adventure", - "name": "Map Your Own Adventure" - }, - { - "id": 1108, - "slug": "algorithmic-puzzles-matchbox", - "name": "Matchbox" - }, - { - "id": 1104, - "slug": "apcsp-internet-simulator-internet-simulator-binary-encodings", - "name": "Internet Simulator: Binary Encodings" - }, - { - "id": 1027, - "slug": "apcsp-explore-task-apcsp-explore-practice", - "name": "APCSP - Explore Practice" - }, - { - "id": 971, - "slug": "wethinkcode_-social-tech-lab-idea-pitch", - "name": "Idea Pitch" - }, - { - "id": 962, - "slug": "joburg-first-internship-contract-upload", - "name": "Contract Upload" - }, - { - "id": 850, - "slug": "piscine-starfleet-day-00", - "name": "Day 00" - }, - { - "id": 834, - "slug": "hercules-nemean-lion", - "name": "Nemean Lion" - }, - { - "id": 831, - "slug": "matrice-matrice-cpa", - "name": "Matrice CPA" - }, - { - "id": 792, - "slug": "piscine-ruby-on-rails-day-00", - "name": "Day 00" - }, - { - "id": 746, - "slug": "piscine-python-django-day-00", - "name": "Day 00" - }, - { - "id": 743, - "slug": "piscine-swift-ios-day-00", - "name": "Day 00" - }, - { - "id": 659, - "slug": "electronics-electronics-selection-test", - "name": "Electronics Selection Test" - }, - { - "id": 634, - "slug": "bootcamp-day-09-00", - "name": "00" - }, - { - "id": 615, - "slug": "open-project-ii", - "name": "Open Project II" - }, - { - "id": 614, - "slug": "open-project-ii-complete-the-project", - "name": "Complete the project" - }, - { - "id": 613, - "slug": "open-project-ii-define-your-subject", - "name": "Define your subject" - }, - { - "id": 591, - "slug": "piscine-c-formation-jour-06", - "name": "Jour 06" - }, - { - "id": 590, - "slug": "piscine-c-formation-jour-05", - "name": "Jour 05" - }, - { - "id": 589, - "slug": "piscine-c-formation-jour-04", - "name": "Jour 04" - }, - { - "id": 588, - "slug": "piscine-c-formation-jour-03", - "name": "Jour 03" - }, - { - "id": 587, - "slug": "piscine-c-formation-jour-02", - "name": "Jour 02" - }, - { - "id": 583, - "slug": "piscine-c-formation-jour-12", - "name": "Jour 12" - }, - { - "id": 582, - "slug": "piscine-c-formation-jour-13", - "name": "Jour 13" - }, - { - "id": 581, - "slug": "piscine-c-formation-jour-10", - "name": "Jour 10" - }, - { - "id": 580, - "slug": "piscine-c-formation-jour-11", - "name": "Jour 11" - }, - { - "id": 551, - "slug": "piscine-c-formation-jour-08", - "name": "Jour 08" - }, - { - "id": 549, - "slug": "piscine-c-formation-jour-07", - "name": "Jour 07" - }, - { - "id": 513, - "slug": "42partnerships-initiation-web-day-00-shell", - "name": "Day 00 - Shell" - }, - { - "id": 507, - "slug": "42partnerships-initiation-ruby-day-00-shell", - "name": "Day 00 - Shell" - }, - { - "id": 505, - "slug": "piscine-c-a-distance-bsq", - "name": "BSQ" - }, - { - "id": 498, - "slug": "piscine-c-a-distance-evalexpr", - "name": "EvalExpr" - }, - { - "id": 496, - "slug": "piscine-c-a-distance-match-n-match", - "name": "Match-N-Match" - }, - { - "id": 494, - "slug": "piscine-c-a-distance-sastantua", - "name": "Sastantua" - }, - { - "id": 489, - "slug": "piscine-c-a-distance-jour-13", - "name": "Jour 13" - }, - { - "id": 487, - "slug": "piscine-c-a-distance-jour-12", - "name": "Jour 12" - }, - { - "id": 485, - "slug": "piscine-c-a-distance-jour-11", - "name": "Jour 11" - }, - { - "id": 483, - "slug": "piscine-c-a-distance-jour-10", - "name": "Jour 10" - }, - { - "id": 459, - "slug": "piscine-c-decloisonnee-pide-jour-09-01", - "name": "01" - }, - { - "id": 458, - "slug": "piscine-c-decloisonnee-pide-jour-09-00", - "name": "00" - }, - { - "id": 457, - "slug": "piscine-c-a-distance-jour-09", - "name": "Jour 09" - }, - { - "id": 434, - "slug": "piscine-c-piadi-jour-09-01", - "name": "01" - }, - { - "id": 433, - "slug": "piscine-c-piadi-jour-09-00", - "name": "00" - }, - { - "id": 431, - "slug": "piscine-c-a-distance-jour-08", - "name": "Jour 08" - }, - { - "id": 430, - "slug": "piscine-c-a-distance-jour-07", - "name": "Jour 07" - }, - { - "id": 427, - "slug": "piscine-c-a-distance-jour-06", - "name": "Jour 06" - }, - { - "id": 425, - "slug": "piscine-c-a-distance-jour-05", - "name": "Jour 05" - }, - { - "id": 424, - "slug": "piscine-c-a-distance-jour-04", - "name": "Jour 04" - }, - { - "id": 422, - "slug": "piscine-c-a-distance-jour-03", - "name": "Jour 03" - }, - { - "id": 420, - "slug": "piscine-c-a-distance-jour-02", - "name": "Jour 02" - }, - { - "id": 418, - "slug": "piscine-c-a-distance-jour-01", - "name": "Jour 01" - }, - { - "id": 416, - "slug": "piscine-c-a-distance-jour-00", - "name": "Jour 00" - }, - { - "id": 409, - "slug": "ft_hangouts", - "name": "ft_hangouts" - }, - { - "id": 381, - "slug": "piscine-unity-day-00", - "name": "Day 00" - }, - { - "id": 372, - "slug": "piscine-ocaml-day-00", - "name": "Day 00" - }, - { - "id": 370, - "slug": "piscine-ocaml", - "name": "Piscine OCaml" - }, - { - "id": 215, - "slug": "communication-trainer-trainees-sessions", - "name": "Trainees sessions" - }, - { - "id": 214, - "slug": "communication-trainer", - "name": "Communication Trainer" - }, - { - "id": 212, - "slug": "final-internship", - "name": "Final Internship" - }, - { - "id": 208, - "slug": "final-internship-contract-upload", - "name": "Contract Upload" - }, - { - "id": 184, - "slug": "part-time", - "name": "Part-time" - }, - { - "id": 180, - "slug": "part-time-contract-upload", - "name": "Contract Upload" - }, - { - "id": 179, - "slug": "strace", - "name": "strace" - }, - { - "id": 178, - "slug": "42run", - "name": "42run" - }, - { - "id": 175, - "slug": "piscine-c-day-09-00", - "name": "00" - }, - { - "id": 174, - "slug": "bsq", - "name": "BSQ" - }, - { - "id": 173, - "slug": "piscine-c-evalexpr", - "name": "EvalExpr" - }, - { - "id": 172, - "slug": "piscine-c-match-n-match", - "name": "Match-N-Match" - }, - { - "id": 171, - "slug": "piscine-c-sastantua", - "name": "Sastantua" - }, - { - "id": 170, - "slug": "piscine-c-rush-02", - "name": "Rush 02" - }, - { - "id": 169, - "slug": "piscine-c-rush-01", - "name": "Rush 01" - }, - { - "id": 168, - "slug": "piscine-c-rush-00", - "name": "Rush 00" - }, - { - "id": 167, - "slug": "piscine-c-day-09", - "name": "Day 09" - }, - { - "id": 166, - "slug": "piscine-c-day-13", - "name": "Day 13" - }, - { - "id": 165, - "slug": "piscine-c-day-12", - "name": "Day 12" - }, - { - "id": 164, - "slug": "piscine-c-day-11", - "name": "Day 11" - }, - { - "id": 163, - "slug": "piscine-c-day-10", - "name": "Day 10" - }, - { - "id": 162, - "slug": "piscine-c-day-08", - "name": "Day 08" - }, - { - "id": 161, - "slug": "piscine-c-day-07", - "name": "Day 07" - }, - { - "id": 160, - "slug": "piscine-c-day-06", - "name": "Day 06" - }, - { - "id": 159, - "slug": "piscine-c-day-05", - "name": "Day 05" - }, - { - "id": 158, - "slug": "piscine-c-day-04", - "name": "Day 04" - }, - { - "id": 157, - "slug": "piscine-c-day-03", - "name": "Day 03" - }, - { - "id": 156, - "slug": "piscine-c-day-02", - "name": "Day 02" - }, - { - "id": 155, - "slug": "piscine-c-day-01", - "name": "Day 01" - }, - { - "id": 154, - "slug": "piscine-c-day-00", - "name": "Day 00" - }, - { - "id": 135, - "slug": "scop", - "name": "Scop" - }, - { - "id": 122, - "slug": "taskmaster", - "name": "Taskmaster" - }, - { - "id": 119, - "slug": "first-internship-contract-upload", - "name": "Contract Upload" - }, - { - "id": 118, - "slug": "first-internship", - "name": "First Internship" - }, - { - "id": 111, - "slug": "computorv1", - "name": "ComputorV1" - }, - { - "id": 107, - "slug": "gomoku", - "name": "Gomoku" - }, - { - "id": 98, - "slug": "expert-system", - "name": "Expert System" - }, - { - "id": 97, - "slug": "n-puzzle", - "name": "N-puzzle" - }, - { - "id": 95, - "slug": "nibbler", - "name": "Nibbler" - }, - { - "id": 89, - "slug": "web-initiation-d00-shell", - "name": "D00 - Shell" - }, - { - "id": 86, - "slug": "open-project-i-define-your-subject", - "name": "Define your subject" - }, - { - "id": 85, - "slug": "open-project-i", - "name": "Open Project I" - }, - { - "id": 63, - "slug": "42-piscine-c-formation-piscine-cpp-day-00", - "name": "Day 00" - }, - { - "id": 49, - "slug": "42-piscine-c-formation-piscine-php-day-00", - "name": "Day 00" - }, - { - "id": 25, - "slug": "rushes-c-hotrace", - "name": "Hotrace" - }, - { - "id": 2505, - "slug": "codam-startup-internship-codam-startup-internship-duration", - "name": "Codam Startup Internship - Duration" - }, - { - "id": 2247, - "slug": "hive-startup-internship-hive-startup-internship-duration", - "name": "Hive Startup Internship - Duration" - }, - { - "id": 2241, - "slug": "hive-internship-hive-internship-duration", - "name": "Hive Internship - Duration" - }, - { - "id": 2070, - "slug": "rushes-hotrace", - "name": "Hotrace" - }, - { - "id": 1877, - "slug": "apprentissage-1-an-apprentissage-1-an-1", - "name": "Apprentissage 1 an - 1" - }, - { - "id": 1869, - "slug": "apprentissage-2-ans-2eme-annee-apprentissage-2-ans-2eme-annee-1", - "name": "Apprentissage 2 ans - 2ème année - 1" - }, - { - "id": 1861, - "slug": "apprentissage-2-ans-1ere-annee-apprentissage-2-ans-1ere-annee-1", - "name": "Apprentissage 2 ans - 1ère année - 1" - }, - { - "id": 1801, - "slug": "piscine-java-day-01", - "name": "Day 01" - }, - { - "id": 1788, - "slug": "piscine-python-data-science-day-01", - "name": "Day 01" - }, - { - "id": 1664, - "slug": "startup-experience-startup-experience-duration", - "name": "Startup Experience - Duration" - }, - { - "id": 1658, - "slug": "part_time-ii-part_time-ii-duration", - "name": "Part_Time II - Duration" - }, - { - "id": 1652, - "slug": "part_time-i-part_time-i-duration", - "name": "Part_Time I - Duration" - }, - { - "id": 1646, - "slug": "work-experience-ii-work-experience-ii-duration", - "name": "Work Experience II - Duration" - }, - { - "id": 1639, - "slug": "work-experience-i-work-experience-i-duration", - "name": "Work Experience I - Duration" - }, - { - "id": 1637, - "slug": "open-project-open-project-complete-the-project", - "name": "Open Project - Complete the project" - }, - { - "id": 1621, - "slug": "42cursus-piscine-python-django-day-01", - "name": "Day 01" - }, - { - "id": 1609, - "slug": "42cursus-piscine-ruby-on-rails-day-01", - "name": "Day 01" - }, - { - "id": 1597, - "slug": "42cursus-piscine-swift-ios-day-01", - "name": "Day 01" - }, - { - "id": 1585, - "slug": "42cursus-piscine-php-symfony-day-01", - "name": "Day 01" - }, - { - "id": 1573, - "slug": "42cursus-piscine-ocaml-day-01", - "name": "Day 01" - }, - { - "id": 1561, - "slug": "42cursus-piscine-unity-day-01", - "name": "Day 01" - }, - { - "id": 1369, - "slug": "data-structures-in-python-part-2-queues-and-stacks", - "name": "Part 2: Queues and Stacks" - }, - { - "id": 1297, - "slug": "api-s-with-node-js-mongodb-setup", - "name": "MongoDB Setup" - }, - { - "id": 1293, - "slug": "java-oop-essentials-in-java", - "name": "OOP Essentials in Java" - }, - { - "id": 1292, - "slug": "pygame-showcase-arcade", - "name": "Showcase: Arcade" - }, - { - "id": 1287, - "slug": "machine-learning-using-python-ml_02", - "name": "ML_02" - }, - { - "id": 1222, - "slug": "javascript-and-graphics-in-p5js-p5js-01", - "name": "p5js-01" - }, - { - "id": 1193, - "slug": "data-mining-the-49ers-mapping-geographical-data-in-plotly", - "name": "Mapping Geographical Data in Plotly" - }, - { - "id": 1149, - "slug": "piscine-php-symfony-day-01", - "name": "Day 01" - }, - { - "id": 1143, - "slug": "parseltongue-piscine-parseltongue-part-1", - "name": "Parseltongue - Part 1" - }, - { - "id": 1105, - "slug": "apcsp-internet-simulator-internet-simulator-network-architecture", - "name": "Internet Simulator: Network Architecture" - }, - { - "id": 1028, - "slug": "deprecated-apcsp-explore-apcsp-explore-portfolio", - "name": "APCSP - Explore Portfolio" - }, - { - "id": 1007, - "slug": "apcsp-prep-apcsp-create-task", - "name": "APCSP - Create Task" - }, - { - "id": 991, - "slug": "algorithmic-puzzles-crypto-intro", - "name": "Crypto intro" - }, - { - "id": 972, - "slug": "wethinkcode_-social-tech-lab-phase-2", - "name": "Phase 2" - }, - { - "id": 963, - "slug": "joburg-first-internship-duration", - "name": "Duration" - }, - { - "id": 869, - "slug": "matrice-matrice-cea", - "name": "Matrice CEA" - }, - { - "id": 851, - "slug": "piscine-starfleet-exam-00", - "name": "Exam 00" - }, - { - "id": 835, - "slug": "hercules-lernaean-hydra", - "name": "Lernaean Hydra" - }, - { - "id": 793, - "slug": "piscine-ruby-on-rails-day-01", - "name": "Day 01" - }, - { - "id": 744, - "slug": "piscine-swift-ios-day-01", - "name": "Day 01" - }, - { - "id": 730, - "slug": "piscine-python-django-day-01", - "name": "Day 01" - }, - { - "id": 703, - "slug": "rushes-factrace", - "name": "Factrace" - }, - { - "id": 666, - "slug": "electronics-electronics-project", - "name": "Electronics Project" - }, - { - "id": 635, - "slug": "bootcamp-day-09-01", - "name": "01" - }, - { - "id": 520, - "slug": "42partnerships-initiation-web-day-01-html-css", - "name": "Day 01 - HTML & CSS" - }, - { - "id": 508, - "slug": "42partnerships-initiation-ruby-day-01-ruby", - "name": "Day 01 - Ruby" - }, - { - "id": 460, - "slug": "piscine-c-decloisonnee-pide-jour-09-02", - "name": "02" - }, - { - "id": 435, - "slug": "piscine-c-piadi-jour-09-02", - "name": "02" - }, - { - "id": 382, - "slug": "piscine-unity-day-01", - "name": "Day 01" - }, - { - "id": 374, - "slug": "piscine-ocaml-day-01", - "name": "Day 01" - }, - { - "id": 216, - "slug": "communication-trainer-training-the-community", - "name": "Training the community" - }, - { - "id": 209, - "slug": "final-internship-duration", - "name": "Duration" - }, - { - "id": 185, - "slug": "piscine-c-day-09-01", - "name": "01" - }, - { - "id": 181, - "slug": "part-time-duration", - "name": "Duration" - }, - { - "id": 140, - "slug": "first-internship-duration", - "name": "Duration" - }, - { - "id": 90, - "slug": "web-initiation-d01-html-css-js", - "name": "D01 - HTML, CSS & JS" - }, - { - "id": 87, - "slug": "open-project-i-complete-the-project", - "name": "Complete the project" - }, - { - "id": 64, - "slug": "42-piscine-c-formation-piscine-cpp-day-01", - "name": "Day 01" - }, - { - "id": 50, - "slug": "42-piscine-c-formation-piscine-php-day-01", - "name": "Day 01" - }, - { - "id": 2506, - "slug": "codam-startup-internship-codam-startup-internship-mid-evaluation", - "name": "Codam Startup Internship - Mid Evaluation" - }, - { - "id": 2248, - "slug": "hive-startup-internship-hive-startup-internship-entrepreneurship-mid-evaluation", - "name": "Hive Startup Internship - Entrepreneurship mid evaluation" - }, - { - "id": 2242, - "slug": "hive-internship-hive-internship-company-mid-evaluation", - "name": "Hive Internship - Company mid evaluation" - }, - { - "id": 2102, - "slug": "42cursus-rushes-alcu", - "name": "AlCu" - }, - { - "id": 1878, - "slug": "apprentissage-1-an-apprentissage-1-an-2", - "name": "Apprentissage 1 an - 2" - }, - { - "id": 1870, - "slug": "apprentissage-2-ans-2eme-annee-apprentissage-2-ans-2eme-annee-2", - "name": "Apprentissage 2 ans - 2ème année - 2" - }, - { - "id": 1862, - "slug": "apprentissage-2-ans-1ere-annee-apprentissage-2-ans-1ere-annee-2", - "name": "Apprentissage 2 ans - 1ère année - 2" - }, - { - "id": 1802, - "slug": "piscine-java-day-02", - "name": "Day 02" - }, - { - "id": 1789, - "slug": "piscine-python-data-science-day-02", - "name": "Day 02" - }, - { - "id": 1665, - "slug": "startup-internship-startup-internship-tutor-mid-evaluation", - "name": "Startup Internship - Tutor Mid Evaluation" - }, - { - "id": 1659, - "slug": "part_time-ii-part_time-ii-company-mid-evaluation", - "name": "Part_Time II - Company Mid Evaluation" - }, - { - "id": 1654, - "slug": "part_time-i-part_time-i-company-mid-evaluation", - "name": "Part_Time I Company Mid Evaluation" - }, - { - "id": 1647, - "slug": "work-experience-ii-work-experience-ii-company-mid-evaluation", - "name": "Work Experience II - Company Mid Evaluation" - }, - { - "id": 1641, - "slug": "work-experience-i-work-experience-i-company-mid-evaluation", - "name": "Work Experience I - Company Mid Evaluation" - }, - { - "id": 1622, - "slug": "42cursus-piscine-python-django-day-02", - "name": "Day 02" - }, - { - "id": 1610, - "slug": "42cursus-piscine-ruby-on-rails-day-02", - "name": "Day 02" - }, - { - "id": 1598, - "slug": "42cursus-piscine-swift-ios-day-02", - "name": "Day 02" - }, - { - "id": 1586, - "slug": "42cursus-piscine-php-symfony-day-02", - "name": "Day 02" - }, - { - "id": 1574, - "slug": "42cursus-piscine-ocaml-day-02", - "name": "Day 02" - }, - { - "id": 1562, - "slug": "42cursus-piscine-unity-day-02", - "name": "Day 02" - }, - { - "id": 1370, - "slug": "data-structures-plants-vs-nonplants", - "name": "Plants vs NonPlants!" - }, - { - "id": 1298, - "slug": "api-s-with-node-js-hosting-on-heroku", - "name": "Hosting on Heroku" - }, - { - "id": 1288, - "slug": "machine-learning-using-python-ml_03", - "name": "ML_03" - }, - { - "id": 1224, - "slug": "javascript-and-graphics-in-p5js-p5js-02", - "name": "p5js-02" - }, - { - "id": 1194, - "slug": "data-mining-the-49ers-api-queries-to-mysportsfeeds", - "name": "API Queries to MySportsFeeds" - }, - { - "id": 1150, - "slug": "piscine-php-symfony-day-02", - "name": "Day 02" - }, - { - "id": 1145, - "slug": "parseltongue-piscine-parseltongue-part-2", - "name": "Parseltongue - Part 2" - }, - { - "id": 1106, - "slug": "electronics-electronics-project-level-up", - "name": "Electronics Project - Level UP" - }, - { - "id": 1092, - "slug": "startup-internship-entrepreneurship-mid-evaluation", - "name": "Entrepreneurship mid evaluation" - }, - { - "id": 988, - "slug": "apcsp-prep-apcsp-explore-task", - "name": "APCSP - Explore Task" - }, - { - "id": 973, - "slug": "wethinkcode_-social-tech-lab-phase-3", - "name": "Phase 3" - }, - { - "id": 964, - "slug": "joburg-first-internship-company-mid-evaluation", - "name": "Company mid evaluation" - }, - { - "id": 852, - "slug": "piscine-starfleet-day-01", - "name": "Day 01" - }, - { - "id": 836, - "slug": "hercules-ceryneian-hind", - "name": "Ceryneian Hind" - }, - { - "id": 828, - "slug": "part-time-company-mid-evaluation", - "name": "Company mid evaluation" - }, - { - "id": 827, - "slug": "final-internship-company-mid-evaluation", - "name": "Company mid evaluation" - }, - { - "id": 826, - "slug": "first-internship-company-mid-evaluation", - "name": "Company mid evaluation" - }, - { - "id": 794, - "slug": "piscine-ruby-on-rails-day-02", - "name": "Day 02" - }, - { - "id": 745, - "slug": "piscine-swift-ios-day-02", - "name": "Day 02" - }, - { - "id": 731, - "slug": "piscine-python-django-day-02", - "name": "Day 02" - }, - { - "id": 697, - "slug": "rushes-lldb", - "name": "LLDB" - }, - { - "id": 636, - "slug": "day-09-02", - "name": "02" - }, - { - "id": 521, - "slug": "42partnerships-initiation-web-day-02-php", - "name": "Day 02 - PHP" - }, - { - "id": 509, - "slug": "42partnerships-initiation-ruby-day-02-ruby", - "name": "Day 02 - Ruby" - }, - { - "id": 461, - "slug": "piscine-c-decloisonnee-pide-jour-09-03", - "name": "03" - }, - { - "id": 436, - "slug": "piscine-c-piadi-jour-09-03", - "name": "03" - }, - { - "id": 383, - "slug": "piscine-unity-day-02", - "name": "Day 02" - }, - { - "id": 375, - "slug": "piscine-ocaml-day-02", - "name": "Day 02" - }, - { - "id": 186, - "slug": "piscine-c-day-09-02", - "name": "02" - }, - { - "id": 92, - "slug": "web-initiation-d02-ratchet-parse", - "name": "D02 - Ratchet & Parse" - }, - { - "id": 65, - "slug": "42-piscine-c-formation-piscine-cpp-day-02", - "name": "Day 02" - }, - { - "id": 51, - "slug": "42-piscine-c-formation-piscine-php-day-02", - "name": "Day 02" - }, - { - "id": 2507, - "slug": "codam-startup-internship-codam-startup-internship-final-evaluation", - "name": "Codam Startup Internship - Final Evaluation" - }, - { - "id": 2249, - "slug": "hive-startup-internship-hive-startup-internship-entrepreneurship-final-evaluation", - "name": "Hive Startup Internship - Entrepreneurship final evaluation" - }, - { - "id": 2243, - "slug": "hive-internship-hive-internship-company-final-evaluation", - "name": "Hive Internship - Company final evaluation" - }, - { - "id": 2122, - "slug": "rushes-wong-kar-wai", - "name": "Wong kar Wai" - }, - { - "id": 1879, - "slug": "apprentissage-1-an-apprentissage-1-an-3", - "name": "Apprentissage 1 an - 3" - }, - { - "id": 1871, - "slug": "apprentissage-2-ans-2eme-annee-apprentissage-2-ans-2eme-annee-3", - "name": "Apprentissage 2 ans - 2ème année - 3" - }, - { - "id": 1863, - "slug": "apprentissage-2-ans-1ere-annee-apprentissage-2-ans-1ere-annee-3", - "name": "Apprentissage 2 ans - 1ère année - 3" - }, - { - "id": 1803, - "slug": "piscine-java-day-03", - "name": "Day 03" - }, - { - "id": 1790, - "slug": "piscine-python-data-science-day-03", - "name": "Day 03" - }, - { - "id": 1748, - "slug": "deprecated-out-with-the-old-owo-deprecated-philosophers-owo", - "name": "[DEPRECATED] Philosophers (OwO)" - }, - { - "id": 1697, - "slug": "apcsp-prep-apcsp-digital-portfolio", - "name": "APCSP - Digital Portfolio" - }, - { - "id": 1666, - "slug": "startup-internship-startup-internship-tutor-final-evaluation", - "name": "Startup Internship - Tutor Final Evaluation" - }, - { - "id": 1660, - "slug": "part_time-ii-part_time-ii-company-final-evaluation", - "name": "Part_Time II - Company Final Evaluation" - }, - { - "id": 1653, - "slug": "part_time-i-part_time-i-company-final-evaluation", - "name": "Part_Time I Company Final Evaluation" - }, - { - "id": 1648, - "slug": "work-experience-ii-work-experience-ii-company-final-evaluation", - "name": "Work Experience II - Company Final Evaluation" - }, - { - "id": 1642, - "slug": "work-experience-i-work-experience-i-company-final-evaluation", - "name": "Work Experience I - Company Final Evaluation" - }, - { - "id": 1623, - "slug": "42cursus-piscine-python-django-day-03", - "name": "Day 03" - }, - { - "id": 1611, - "slug": "42cursus-piscine-ruby-on-rails-day-03", - "name": "Day 03" - }, - { - "id": 1599, - "slug": "42cursus-piscine-swift-ios-day-03", - "name": "Day 03" - }, - { - "id": 1587, - "slug": "42cursus-piscine-php-symfony-day-03", - "name": "Day 03" - }, - { - "id": 1575, - "slug": "42cursus-piscine-ocaml-day-03", - "name": "Day 03" - }, - { - "id": 1563, - "slug": "42cursus-piscine-unity-day-03", - "name": "Day 03" - }, - { - "id": 1357, - "slug": "algorithmic-puzzles-fractal", - "name": "Fractal" - }, - { - "id": 1289, - "slug": "machine-learning-using-python-ml_04", - "name": "ML_04" - }, - { - "id": 1225, - "slug": "javascript-and-graphics-in-p5js-p5js-03", - "name": "p5js-03" - }, - { - "id": 1201, - "slug": "javascript-jquery", - "name": "jQuery" - }, - { - "id": 1195, - "slug": "data-mining-the-49ers-statistical-data-visualization-with-seaborn", - "name": "Statistical Data Visualization with Seaborn" - }, - { - "id": 1151, - "slug": "piscine-php-symfony-day-03", - "name": "Day 03" - }, - { - "id": 1144, - "slug": "parseltongue-piscine-parseltongue-part-3", - "name": "Parseltongue - Part 3" - }, - { - "id": 1096, - "slug": "matrice-matrice-arts-numerique", - "name": "Matrice Arts & Numérique" - }, - { - "id": 1093, - "slug": "startup-internship-entrepreneurship-final-evaluation", - "name": "Entrepreneurship final evaluation" - }, - { - "id": 975, - "slug": "wethinkcode_-social-tech-lab-final-jury", - "name": "Final Jury" - }, - { - "id": 965, - "slug": "joburg-first-internship-company-final-evaluation", - "name": "Company final evaluation" - }, - { - "id": 853, - "slug": "piscine-starfleet-day-02", - "name": "Day 02" - }, - { - "id": 837, - "slug": "hercules-erymanthian-boar", - "name": "Erymanthian Boar" - }, - { - "id": 795, - "slug": "piscine-ruby-on-rails-day-03", - "name": "Day 03" - }, - { - "id": 747, - "slug": "piscine-swift-ios-day-03", - "name": "Day 03" - }, - { - "id": 732, - "slug": "piscine-python-django-day-03", - "name": "Day 03" - }, - { - "id": 684, - "slug": "rushes-puissance-4", - "name": "Puissance 4" - }, - { - "id": 637, - "slug": "day-09-03", - "name": "03" - }, - { - "id": 510, - "slug": "42partnerships-initiation-ruby-day-03-ruby", - "name": "Day 03 - Ruby" - }, - { - "id": 462, - "slug": "piscine-c-decloisonnee-pide-jour-09-04", - "name": "04" - }, - { - "id": 437, - "slug": "piscine-c-piadi-jour-09-04", - "name": "04" - }, - { - "id": 384, - "slug": "piscine-unity-day-03", - "name": "Day 03" - }, - { - "id": 377, - "slug": "piscine-ocaml-day-03", - "name": "Day 03" - }, - { - "id": 210, - "slug": "final-internship-company-final-evaluation", - "name": "Company final evaluation" - }, - { - "id": 187, - "slug": "piscine-c-day-09-03", - "name": "03" - }, - { - "id": 182, - "slug": "part-time-company-final-evaluation", - "name": "Company final evaluation" - }, - { - "id": 120, - "slug": "first-internship-company-final-evaluation", - "name": "Company final evaluation" - }, - { - "id": 66, - "slug": "42-piscine-c-formation-piscine-cpp-day-03", - "name": "Day 03" - }, - { - "id": 52, - "slug": "42-piscine-c-formation-piscine-php-day-03", - "name": "Day 03" - }, - { - "id": 2508, - "slug": "codam-startup-internship-codam-startup-internship-peer-video", - "name": "Codam Startup Internship - Peer Video" - }, - { - "id": 2250, - "slug": "hive-startup-internship-hive-startup-internship-peer-video", - "name": "Hive Startup Internship - Peer Video" - }, - { - "id": 2244, - "slug": "hive-internship-hive-internship-peer-video", - "name": "Hive Internship - Peer video" - }, - { - "id": 2136, - "slug": "42cursus-rushes-yasl", - "name": "yasl" - }, - { - "id": 1880, - "slug": "apprentissage-1-an-apprentissage-1-an-4", - "name": "Apprentissage 1 an - 4" - }, - { - "id": 1872, - "slug": "apprentissage-2-ans-2eme-annee-apprentissage-2-ans-2eme-annee-4", - "name": "Apprentissage 2 ans - 2ème année - 4" - }, - { - "id": 1864, - "slug": "apprentissage-2-ans-1ere-annee-apprentissage-2-ans-1ere-annee-4", - "name": "Apprentissage 2 ans - 1ère année - 4" - }, - { - "id": 1804, - "slug": "piscine-java-day-04", - "name": "Day 04" - }, - { - "id": 1791, - "slug": "piscine-python-data-science-day-04", - "name": "Day 04" - }, - { - "id": 1712, - "slug": "machine-learning-ibm-machine-learning-00", - "name": "IBM Machine Learning 00" - }, - { - "id": 1667, - "slug": "startup-internship-startup-internship-peer-video", - "name": "Startup Internship - Peer Video" - }, - { - "id": 1661, - "slug": "part_time-ii-part_time-ii-peer-video", - "name": "Part_Time II - Peer Video" - }, - { - "id": 1655, - "slug": "part_time-i-part_time-i-peer-video", - "name": "Part_Time I Peer Video" - }, - { - "id": 1649, - "slug": "work-experience-ii-work-experience-ii-peer-video", - "name": "Work Experience II - Peer Video" - }, - { - "id": 1643, - "slug": "work-experience-i-work-experience-i-peer-video", - "name": "Work Experience I - Peer Video" - }, - { - "id": 1624, - "slug": "42cursus-piscine-python-django-day-04", - "name": "Day 04" - }, - { - "id": 1612, - "slug": "42cursus-piscine-ruby-on-rails-day-04", - "name": "Day 04" - }, - { - "id": 1600, - "slug": "42cursus-piscine-swift-ios-day-04", - "name": "Day 04" - }, - { - "id": 1588, - "slug": "42cursus-piscine-php-symfony-day-04", - "name": "Day 04" - }, - { - "id": 1576, - "slug": "42cursus-piscine-ocaml-day-04", - "name": "Day 04" - }, - { - "id": 1564, - "slug": "42cursus-piscine-unity-day-04", - "name": "Day 04" - }, - { - "id": 1366, - "slug": "apcsp-prep-apcsp-vocabulary", - "name": "APCSP - Vocabulary" - }, - { - "id": 1358, - "slug": "algorithmic-puzzles-connect-4", - "name": "Connect-4" - }, - { - "id": 1294, - "slug": "javascript-web02-freecodecamp-js", - "name": "Web02 - FreeCodeCamp JS" - }, - { - "id": 1229, - "slug": "javascript-and-graphics-in-p5js-p5js-04", - "name": "p5js-04" - }, - { - "id": 1152, - "slug": "piscine-php-symfony-day-04", - "name": "Day 04" - }, - { - "id": 1146, - "slug": "parseltongue-piscine-parseltongue-part-4", - "name": "Parseltongue - Part 4" - }, - { - "id": 1094, - "slug": "startup-internship-peer-video", - "name": "Peer Video" - }, - { - "id": 966, - "slug": "joburg-first-internship-peer-video", - "name": "Peer Video" - }, - { - "id": 855, - "slug": "piscine-starfleet-day-03", - "name": "Day 03" - }, - { - "id": 838, - "slug": "hercules-augean-stables", - "name": "Augean stables" - }, - { - "id": 796, - "slug": "piscine-ruby-on-rails-day-04", - "name": "Day 04" - }, - { - "id": 748, - "slug": "piscine-swift-ios-day-04", - "name": "Day 04" - }, - { - "id": 733, - "slug": "piscine-python-django-day-04", - "name": "Day 04" - }, - { - "id": 685, - "slug": "rushes-domino", - "name": "Domino" - }, - { - "id": 638, - "slug": "day-09-04", - "name": "04" - }, - { - "id": 463, - "slug": "piscine-c-decloisonnee-pide-jour-09-05", - "name": "05" - }, - { - "id": 438, - "slug": "piscine-c-piadi-jour-09-05", - "name": "05" - }, - { - "id": 385, - "slug": "piscine-unity-day-04", - "name": "Day 04" - }, - { - "id": 379, - "slug": "piscine-ocaml-day-04", - "name": "Day 04" - }, - { - "id": 211, - "slug": "final-internship-peer-video", - "name": "Peer Video" - }, - { - "id": 188, - "slug": "piscine-c-day-09-04", - "name": "04" - }, - { - "id": 183, - "slug": "part-time-peer-video", - "name": "Peer Video" - }, - { - "id": 121, - "slug": "first-internship-peer-video", - "name": "Peer Video" - }, - { - "id": 67, - "slug": "42-piscine-c-formation-piscine-cpp-day-04", - "name": "Day 04" - }, - { - "id": 53, - "slug": "42-piscine-c-formation-piscine-php-day-04", - "name": "Day 04" - }, - { - "id": 2173, - "slug": "42cursus-rushes-wordle", - "name": "wordle" - }, - { - "id": 2092, - "slug": "apprentissage-2-ans-1ere-annee-apprentissage-2-ans-1ere-annee-begin-evaluation", - "name": "Apprentissage 2 ans - 1ère année - Begin evaluation" - }, - { - "id": 2091, - "slug": "apprentissage-2-ans-2eme-annee-apprentissage-2-ans-2eme-annee-mid-evaluation", - "name": "Apprentissage 2 ans - 2ème année - Mid evaluation" - }, - { - "id": 2090, - "slug": "apprentissage-1-an-apprentissage-1-an-begin-evaluation", - "name": "Apprentissage 1 an - Begin evaluation" - }, - { - "id": 1805, - "slug": "piscine-java-day-05", - "name": "Day 05" - }, - { - "id": 1792, - "slug": "piscine-python-data-science-day-05", - "name": "Day 05" - }, - { - "id": 1749, - "slug": "deprecated-out-with-the-old-owo-deprecated-webserv-owo", - "name": "[DEPRECATED] webserv (OwO)" - }, - { - "id": 1713, - "slug": "machine-learning-ibm-machine-learning-01", - "name": "IBM Machine Learning 01" - }, - { - "id": 1694, - "slug": "javascript-web-basics-01-recipe", - "name": "Web Basics 01 - Recipe" - }, - { - "id": 1681, - "slug": "apcsp-programming-pygame", - "name": "Pygame" - }, - { - "id": 1625, - "slug": "42cursus-piscine-python-django-day-05", - "name": "Day 05" - }, - { - "id": 1613, - "slug": "42cursus-piscine-ruby-on-rails-day-05", - "name": "Day 05" - }, - { - "id": 1601, - "slug": "42cursus-piscine-swift-ios-day-05", - "name": "Day 05" - }, - { - "id": 1589, - "slug": "42cursus-piscine-php-symfony-day-05", - "name": "Day 05" - }, - { - "id": 1577, - "slug": "42cursus-piscine-ocaml-day-05", - "name": "Day 05" - }, - { - "id": 1565, - "slug": "42cursus-piscine-unity-day-05", - "name": "Day 05" - }, - { - "id": 1363, - "slug": "apcsp-prep-apcsp-practice-exam", - "name": "APCSP - Practice Exam" - }, - { - "id": 1359, - "slug": "algorithmic-puzzles-game-of-life", - "name": "Game Of Life" - }, - { - "id": 1313, - "slug": "python-showcase-command-line-games", - "name": "Showcase: Command-Line Games" - }, - { - "id": 1231, - "slug": "javascript-and-graphics-in-p5js-p5js-05", - "name": "p5js-05" - }, - { - "id": 1153, - "slug": "piscine-php-symfony-rush00", - "name": "Rush00" - }, - { - "id": 857, - "slug": "piscine-starfleet-exam-02", - "name": "Exam 02" - }, - { - "id": 839, - "slug": "hercules-stymphalian-birds", - "name": "Stymphalian Birds" - }, - { - "id": 797, - "slug": "piscine-ruby-on-rails-rush00", - "name": "Rush00" - }, - { - "id": 749, - "slug": "piscine-swift-ios-rush00", - "name": "Rush00" - }, - { - "id": 734, - "slug": "piscine-python-django-rush00", - "name": "Rush00" - }, - { - "id": 639, - "slug": "day-09-05", - "name": "05" - }, - { - "id": 512, - "slug": "42partnerships-initiation-ruby-rush-00-rpg_txt", - "name": "Rush 00 - rpg_txt" - }, - { - "id": 464, - "slug": "piscine-c-decloisonnee-pide-jour-09-06", - "name": "06" - }, - { - "id": 439, - "slug": "piscine-c-piadi-jour-09-06", - "name": "06" - }, - { - "id": 399, - "slug": "piscine-ocaml-rush00", - "name": "Rush00" - }, - { - "id": 386, - "slug": "piscine-unity-rush00", - "name": "Rush00" - }, - { - "id": 189, - "slug": "piscine-c-day-09-05", - "name": "05" - }, - { - "id": 69, - "slug": "piscine-cpp-rush00", - "name": "Rush00" - }, - { - "id": 59, - "slug": "piscine-php-rush00", - "name": "Rush00" - }, - { - "id": 18, - "slug": "rushes-introduction-to-ios", - "name": "Introduction to iOS" - }, - { - "id": 2174, - "slug": "rushes-connect4", - "name": "Connect4" - }, - { - "id": 2093, - "slug": "apprentissage-2-ans-1ere-annee-apprentissage-2-ans-1ere-annee-annual-evaluation", - "name": "Apprentissage 2 ans - 1ère année - Annual Evaluation" - }, - { - "id": 1876, - "slug": "apprentissage-1-an-apprentissage-1-an-final-evaluation", - "name": "Apprentissage 1 an - Final evaluation" - }, - { - "id": 1868, - "slug": "apprentissage-2-ans-2eme-annee-apprentissage-2-ans-2eme-annee-final-evaluation", - "name": "Apprentissage 2 ans - 2ème année - Final evaluation" - }, - { - "id": 1806, - "slug": "piscine-java-day-06", - "name": "Day 06" - }, - { - "id": 1793, - "slug": "piscine-python-data-science-day-06", - "name": "Day 06" - }, - { - "id": 1751, - "slug": "deprecated-out-with-the-old-owo-deprecated-ft_transcendence-owo", - "name": " [DEPRECATED] ft_transcendence (OwO)" - }, - { - "id": 1695, - "slug": "javascript-web-basics-02-pinterest", - "name": "Web Basics 02 - Pinterest" - }, - { - "id": 1626, - "slug": "42cursus-piscine-python-django-day-06", - "name": "Day 06" - }, - { - "id": 1614, - "slug": "42cursus-piscine-ruby-on-rails-day-06", - "name": "Day 06" - }, - { - "id": 1602, - "slug": "42cursus-piscine-swift-ios-day-06", - "name": "Day 06" - }, - { - "id": 1590, - "slug": "42cursus-piscine-php-symfony-day-06", - "name": "Day 06" - }, - { - "id": 1578, - "slug": "42cursus-piscine-ocaml-day-06", - "name": "Day 06" - }, - { - "id": 1566, - "slug": "42cursus-piscine-unity-day-06", - "name": "Day 06" - }, - { - "id": 1365, - "slug": "apcsp-prep-apcsp-internet-simulator", - "name": "APCSP - Internet Simulator" - }, - { - "id": 1360, - "slug": "algorithmic-puzzles-sonicpi", - "name": "SonicPi" - }, - { - "id": 1154, - "slug": "piscine-php-symfony-day-05", - "name": "Day 05" - }, - { - "id": 858, - "slug": "piscine-starfleet-rush-00", - "name": "Rush 00" - }, - { - "id": 840, - "slug": "hercules-cretan-bull", - "name": "Cretan Bull" - }, - { - "id": 798, - "slug": "piscine-ruby-on-rails-day-05", - "name": "Day 05" - }, - { - "id": 750, - "slug": "piscine-swift-ios-day-05", - "name": "Day 05" - }, - { - "id": 735, - "slug": "piscine-python-django-day-05", - "name": "Day 05" - }, - { - "id": 640, - "slug": "day-09-06", - "name": "06" - }, - { - "id": 465, - "slug": "piscine-c-decloisonnee-pide-jour-09-07", - "name": "07" - }, - { - "id": 440, - "slug": "piscine-c-piadi-jour-09-07", - "name": "07" - }, - { - "id": 388, - "slug": "piscine-unity-day-05", - "name": "Day 05" - }, - { - "id": 380, - "slug": "piscine-ocaml-day-05", - "name": "Day 05" - }, - { - "id": 190, - "slug": "piscine-c-day-09-06", - "name": "06" - }, - { - "id": 54, - "slug": "42-piscine-c-formation-piscine-php-day-05", - "name": "Day 05" - }, - { - "id": 14, - "slug": "rushes-introduction-to-wordpress", - "name": "Introduction to Wordpress" - }, - { - "id": 2210, - "slug": "rushes-retro-mfa", - "name": "Retro-MFA" - }, - { - "id": 1807, - "slug": "piscine-java-day-07", - "name": "Day 07" - }, - { - "id": 1794, - "slug": "piscine-python-data-science-day-07", - "name": "Day 07" - }, - { - "id": 1766, - "slug": "deprecated-out-with-the-old-owo-deprecated-cpp-module-00-owo", - "name": "[DEPRECATED] CPP Module 00 (OwO)" - }, - { - "id": 1627, - "slug": "42cursus-piscine-python-django-day-07", - "name": "Day 07" - }, - { - "id": 1615, - "slug": "42cursus-piscine-ruby-on-rails-day-07", - "name": "Day 07" - }, - { - "id": 1603, - "slug": "42cursus-piscine-swift-ios-day-07", - "name": "Day 07" - }, - { - "id": 1591, - "slug": "42cursus-piscine-php-symfony-day-07", - "name": "Day 07" - }, - { - "id": 1579, - "slug": "42cursus-piscine-ocaml-day-07", - "name": "Day 07" - }, - { - "id": 1567, - "slug": "42cursus-piscine-unity-day-07", - "name": "Day 07" - }, - { - "id": 1155, - "slug": "piscine-php-symfony-day-06", - "name": "Day 06" - }, - { - "id": 859, - "slug": "piscine-starfleet-day-04", - "name": "Day 04" - }, - { - "id": 841, - "slug": "hercules-mares-of-diomedes", - "name": "Mares of Diomedes" - }, - { - "id": 799, - "slug": "piscine-ruby-on-rails-day-06", - "name": "Day 06" - }, - { - "id": 751, - "slug": "piscine-swift-ios-day-06", - "name": "Day 06" - }, - { - "id": 736, - "slug": "piscine-python-django-day-06", - "name": "Day 06" - }, - { - "id": 641, - "slug": "day-09-07", - "name": "07" - }, - { - "id": 466, - "slug": "piscine-c-decloisonnee-pide-jour-09-08", - "name": "08" - }, - { - "id": 441, - "slug": "piscine-c-piadi-jour-09-08", - "name": "08" - }, - { - "id": 395, - "slug": "piscine-ocaml-day-06", - "name": "Day 06" - }, - { - "id": 389, - "slug": "piscine-unity-day-06", - "name": "Day 06" - }, - { - "id": 191, - "slug": "piscine-c-day-09-07", - "name": "07" - }, - { - "id": 141, - "slug": "rushes-arkanoid", - "name": "Arkanoid" - }, - { - "id": 70, - "slug": "42-piscine-c-formation-piscine-cpp-day-05", - "name": "Day 05" - }, - { - "id": 55, - "slug": "42-piscine-c-formation-piscine-php-day-06", - "name": "Day 06" - }, - { - "id": 2214, - "slug": "rushes-ft_shmup", - "name": "ft_shmup " - }, - { - "id": 1808, - "slug": "piscine-java-day-08", - "name": "Day 08" - }, - { - "id": 1795, - "slug": "piscine-python-data-science-day-08", - "name": "Day 08" - }, - { - "id": 1775, - "slug": "deprecated-out-with-the-old-owo-deprecated-cpp-module-01-owo", - "name": "[DEPRECATED] CPP Module 01 (OwO)" - }, - { - "id": 1628, - "slug": "42cursus-piscine-python-django-day-08", - "name": "Day 08" - }, - { - "id": 1616, - "slug": "42cursus-piscine-ruby-on-rails-day-08", - "name": "Day 08" - }, - { - "id": 1604, - "slug": "42cursus-piscine-swift-ios-day-08", - "name": "Day 08" - }, - { - "id": 1592, - "slug": "42cursus-piscine-php-symfony-day-08", - "name": "Day 08" - }, - { - "id": 1580, - "slug": "42cursus-piscine-ocaml-day-08", - "name": "Day 08" - }, - { - "id": 1568, - "slug": "42cursus-piscine-unity-day-08", - "name": "Day 08" - }, - { - "id": 1156, - "slug": "piscine-php-symfony-day-07", - "name": "Day 07" - }, - { - "id": 860, - "slug": "piscine-starfleet-exam-03", - "name": "Exam 03" - }, - { - "id": 842, - "slug": "hercules-girdle-of-hippolyta", - "name": "Girdle of Hippolyta" - }, - { - "id": 800, - "slug": "piscine-ruby-on-rails-day-07", - "name": "Day 07" - }, - { - "id": 752, - "slug": "piscine-swift-ios-day-07", - "name": "Day 07" - }, - { - "id": 737, - "slug": "piscine-python-django-day-07", - "name": "Day 07" - }, - { - "id": 642, - "slug": "day-09-08", - "name": "08" - }, - { - "id": 467, - "slug": "piscine-c-decloisonnee-pide-jour-09-09", - "name": "09" - }, - { - "id": 442, - "slug": "piscine-c-piadi-jour-09-09", - "name": "09" - }, - { - "id": 396, - "slug": "piscine-ocaml-day-07", - "name": "Day 07" - }, - { - "id": 390, - "slug": "piscine-unity-day-07", - "name": "Day 07" - }, - { - "id": 192, - "slug": "piscine-c-day-09-08", - "name": "08" - }, - { - "id": 93, - "slug": "rushes-wong_kar_wai", - "name": "wong_kar_wai" - }, - { - "id": 71, - "slug": "42-piscine-c-formation-piscine-cpp-day-06", - "name": "Day 06" - }, - { - "id": 56, - "slug": "42-piscine-c-formation-piscine-php-day-07", - "name": "Day 07" - }, - { - "id": 1809, - "slug": "piscine-java-day-09", - "name": "Day 09" - }, - { - "id": 1796, - "slug": "piscine-python-data-science-day-09", - "name": "Day 09" - }, - { - "id": 1776, - "slug": "out-with-the-old-owo-cpp-module-02-owo", - "name": "CPP Module 02 (OwO)" - }, - { - "id": 1629, - "slug": "42cursus-piscine-python-django-day-09", - "name": "Day 09" - }, - { - "id": 1617, - "slug": "42cursus-piscine-ruby-on-rails-day-09", - "name": "Day 09" - }, - { - "id": 1605, - "slug": "42cursus-piscine-swift-ios-day-09", - "name": "Day 09" - }, - { - "id": 1593, - "slug": "42cursus-piscine-php-symfony-day-09", - "name": "Day 09" - }, - { - "id": 1581, - "slug": "42cursus-piscine-ocaml-day-09", - "name": "Day 09" - }, - { - "id": 1569, - "slug": "42cursus-piscine-unity-day-09", - "name": "Day 09" - }, - { - "id": 1157, - "slug": "piscine-php-symfony-day-08", - "name": "Day 08" - }, - { - "id": 862, - "slug": "piscine-starfleet-day-05", - "name": "Day 05" - }, - { - "id": 843, - "slug": "hercules-cattle-of-geryon", - "name": "Cattle of Geryon" - }, - { - "id": 801, - "slug": "piscine-ruby-on-rails-day-08", - "name": "Day 08" - }, - { - "id": 753, - "slug": "piscine-swift-ios-day-08", - "name": "Day 08" - }, - { - "id": 738, - "slug": "piscine-python-django-day-08", - "name": "Day 08" - }, - { - "id": 663, - "slug": "rushes-carnifex-lisp", - "name": "Carnifex (LISP)" - }, - { - "id": 643, - "slug": "day-09-09", - "name": "09" - }, - { - "id": 468, - "slug": "piscine-c-decloisonnee-pide-jour-09-10", - "name": "10" - }, - { - "id": 443, - "slug": "piscine-c-piadi-jour-09-10", - "name": "10" - }, - { - "id": 397, - "slug": "piscine-ocaml-day-08", - "name": "Day 08" - }, - { - "id": 394, - "slug": "piscine-unity", - "name": "Piscine Unity" - }, - { - "id": 391, - "slug": "piscine-unity-day-08", - "name": "Day 08" - }, - { - "id": 193, - "slug": "piscine-c-day-09-09", - "name": "09" - }, - { - "id": 72, - "slug": "42-piscine-c-formation-piscine-cpp-day-07", - "name": "Day 07" - }, - { - "id": 57, - "slug": "42-piscine-c-formation-piscine-php-day-08", - "name": "Day 08" - }, - { - "id": 1810, - "slug": "piscine-java-rush-00", - "name": "Rush 00" - }, - { - "id": 1797, - "slug": "piscine-python-data-science-rush-00", - "name": "Rush 00" - }, - { - "id": 1777, - "slug": "out-with-the-old-owo-cpp-module-03-owo", - "name": "CPP Module 03 (OwO)" - }, - { - "id": 1706, - "slug": "42cursus-piscine-unity-rush-00", - "name": "Rush 00" - }, - { - "id": 1704, - "slug": "42cursus-piscine-swift-ios-rush-01", - "name": "Rush 01" - }, - { - "id": 1703, - "slug": "42cursus-piscine-php-symfony-rush-00", - "name": "Rush 00" - }, - { - "id": 1700, - "slug": "42cursus-piscine-ruby-on-rails-rush-00", - "name": "Rush 00" - }, - { - "id": 1698, - "slug": "42cursus-piscine-ocaml-rush-00", - "name": "Rush 00" - }, - { - "id": 1630, - "slug": "42cursus-piscine-python-django-rush-00", - "name": "Rush 00" - }, - { - "id": 1272, - "slug": "matrice-matrice-sante", - "name": "Matrice Santé" - }, - { - "id": 1160, - "slug": "piscine-php-symfony-rush01", - "name": "Rush01" - }, - { - "id": 1158, - "slug": "piscine-php-symfony-day-09", - "name": "Day 09" - }, - { - "id": 863, - "slug": "piscine-starfleet-day-06", - "name": "Day 06" - }, - { - "id": 844, - "slug": "hercules-apples-of-the-hesperides", - "name": "Apples of the Hesperides" - }, - { - "id": 803, - "slug": "piscine-ruby-on-rails-rush01", - "name": "Rush01" - }, - { - "id": 802, - "slug": "piscine-ruby-on-rails-day-09", - "name": "Day 09" - }, - { - "id": 755, - "slug": "piscine-swift-ios-rush01", - "name": "Rush01" - }, - { - "id": 754, - "slug": "piscine-swift-ios-day-09", - "name": "Day 09" - }, - { - "id": 740, - "slug": "piscine-python-django-day-09", - "name": "Day 09" - }, - { - "id": 644, - "slug": "day-09-10", - "name": "10" - }, - { - "id": 469, - "slug": "piscine-c-decloisonnee-pide-jour-09-11", - "name": "11" - }, - { - "id": 444, - "slug": "piscine-c-piadi-jour-09-11", - "name": "11" - }, - { - "id": 401, - "slug": "piscine-unity-day-09", - "name": "Day 09" - }, - { - "id": 400, - "slug": "piscine-ocaml-rush01", - "name": "Rush01" - }, - { - "id": 398, - "slug": "piscine-ocaml-day-09", - "name": "Day 09" - }, - { - "id": 393, - "slug": "piscine-unity-rush01", - "name": "Rush01" - }, - { - "id": 194, - "slug": "piscine-c-day-09-10", - "name": "10" - }, - { - "id": 114, - "slug": "rushes-cluedo-prolog", - "name": "Cluedo (Prolog)" - }, - { - "id": 73, - "slug": "42-piscine-c-formation-piscine-cpp-day-08", - "name": "Day 08" - }, - { - "id": 58, - "slug": "42-piscine-c-formation-piscine-php-day-09", - "name": "Day 09" - }, - { - "id": 1815, - "slug": "piscine-python-data-science-rush-01", - "name": "Rush 01" - }, - { - "id": 1811, - "slug": "piscine-java-rush-01", - "name": "Rush 01" - }, - { - "id": 1778, - "slug": "out-with-the-old-owo-cpp-module-04-owo", - "name": "CPP Module 04 (OwO)" - }, - { - "id": 1707, - "slug": "42cursus-piscine-unity-rush-01", - "name": "Rush 01" - }, - { - "id": 1705, - "slug": "42cursus-piscine-swift-ios-rush-00", - "name": "Rush 00" - }, - { - "id": 1702, - "slug": "42cursus-piscine-php-symfony-rush-01", - "name": "Rush 01" - }, - { - "id": 1701, - "slug": "42cursus-piscine-ruby-on-rails-rush-01", - "name": "Rush 01" - }, - { - "id": 1699, - "slug": "42cursus-piscine-ocaml-rush-01", - "name": "Rush 01" - }, - { - "id": 1631, - "slug": "42cursus-piscine-python-django-rush-01", - "name": "Rush 01" - }, - { - "id": 1618, - "slug": "piscine-ruby-on-rails-rush-00", - "name": "Rush 00" - }, - { - "id": 1607, - "slug": "piscine-swift-ios-rush-01", - "name": "Rush 01" - }, - { - "id": 1594, - "slug": "piscine-php-symfony-rush-00", - "name": "Rush 00" - }, - { - "id": 1582, - "slug": "piscine-ocaml-rush-00", - "name": "Rush 00" - }, - { - "id": 865, - "slug": "piscine-starfleet-day-07", - "name": "Day 07" - }, - { - "id": 845, - "slug": "hercules-capturing-cerberus", - "name": "Capturing Cerberus" - }, - { - "id": 741, - "slug": "piscine-python-django-rush01", - "name": "Rush01" - }, - { - "id": 662, - "slug": "rushes-yasl", - "name": "YASL" - }, - { - "id": 645, - "slug": "day-09-11", - "name": "11" - }, - { - "id": 470, - "slug": "piscine-c-decloisonnee-pide-jour-09-12", - "name": "12" - }, - { - "id": 445, - "slug": "piscine-c-piadi-jour-09-12", - "name": "12" - }, - { - "id": 195, - "slug": "piscine-c-day-09-11", - "name": "11" - }, - { - "id": 94, - "slug": "root-me-app-systeme", - "name": "Root-me | App-Systeme" - }, - { - "id": 60, - "slug": "piscine-php-rush01", - "name": "Rush01" - }, - { - "id": 1779, - "slug": "out-with-the-old-owo-cpp-module-05-owo", - "name": "CPP Module 05 (OwO)" - }, - { - "id": 1619, - "slug": "piscine-ruby-on-rails-rush-01", - "name": "Rush 01" - }, - { - "id": 1606, - "slug": "piscine-swift-ios-rush-00", - "name": "Rush 00" - }, - { - "id": 1595, - "slug": "piscine-php-symfony-rush-01", - "name": "Rush 01" - }, - { - "id": 1583, - "slug": "piscine-ocaml-rush-01", - "name": "Rush 01" - }, - { - "id": 867, - "slug": "piscine-starfleet-exam-05", - "name": "Exam 05" - }, - { - "id": 646, - "slug": "day-09-12", - "name": "12" - }, - { - "id": 602, - "slug": "rushes-rage-against-the-api", - "name": "Rage Against The aPi" - }, - { - "id": 471, - "slug": "piscine-c-decloisonnee-pide-jour-09-13", - "name": "13" - }, - { - "id": 446, - "slug": "piscine-c-piadi-jour-09-13", - "name": "13" - }, - { - "id": 403, - "slug": "corewar-championship", - "name": "Corewar Championship" - }, - { - "id": 196, - "slug": "piscine-c-day-09-12", - "name": "12" - }, - { - "id": 76, - "slug": "piscine-cpp-rush01", - "name": "Rush01" - }, - { - "id": 1780, - "slug": "out-with-the-old-owo-cpp-module-06-owo", - "name": "CPP Module 06 (OwO)" - }, - { - "id": 868, - "slug": "piscine-starfleet-rush-01", - "name": "Rush 01" - }, - { - "id": 647, - "slug": "day-09-13", - "name": "13" - }, - { - "id": 472, - "slug": "piscine-c-decloisonnee-pide-jour-09-14", - "name": "14" - }, - { - "id": 447, - "slug": "piscine-c-piadi-jour-09-14", - "name": "14" - }, - { - "id": 197, - "slug": "piscine-c-day-09-13", - "name": "13" - }, - { - "id": 28, - "slug": "rushes-alcu", - "name": "AlCu" - }, - { - "id": 1781, - "slug": "deprecated-out-with-the-old-owo-deprecated-cpp-module-07-owo", - "name": "[DEPRECATED] CPP Module 07 (OwO)" - }, - { - "id": 648, - "slug": "day-09-14", - "name": "14" - }, - { - "id": 599, - "slug": "rushes-mexican-standoff", - "name": "Mexican Standoff" - }, - { - "id": 473, - "slug": "piscine-c-decloisonnee-pide-jour-09-15", - "name": "15" - }, - { - "id": 448, - "slug": "piscine-c-piadi-jour-09-15", - "name": "15" - }, - { - "id": 198, - "slug": "piscine-c-day-09-14", - "name": "14" - }, - { - "id": 1767, - "slug": "deprecated-out-with-the-old-owo-deprecated-cpp-module-08-owo", - "name": "[DEPRECATED] CPP Module 08 (OwO)" - }, - { - "id": 1300, - "slug": "matrice-matrice-air-data", - "name": "Matrice Air Data" - }, - { - "id": 649, - "slug": "day-09-15", - "name": "15" - }, - { - "id": 474, - "slug": "piscine-c-decloisonnee-pide-jour-09-16", - "name": "16" - }, - { - "id": 449, - "slug": "piscine-c-piadi-jour-09-16", - "name": "16" - }, - { - "id": 405, - "slug": "piscine-c-exam01", - "name": "Exam01" - }, - { - "id": 199, - "slug": "piscine-c-day-09-15", - "name": "15" - }, - { - "id": 108, - "slug": "rushes-rush-network-and-system-administration-0", - "name": "Rush Network and System Administration #0" - }, - { - "id": 1634, - "slug": "matrice-matrice-solutions-urbaines", - "name": "Matrice Solutions Urbaines" - }, - { - "id": 650, - "slug": "day-09-16", - "name": "16" - }, - { - "id": 586, - "slug": "piscine-c-formation-exam-final", - "name": "Exam Final" - }, - { - "id": 475, - "slug": "piscine-c-decloisonnee-pide-jour-09-17", - "name": "17" - }, - { - "id": 450, - "slug": "piscine-c-piadi-jour-09-17", - "name": "17" - }, - { - "id": 200, - "slug": "piscine-c-day-09-16", - "name": "16" - }, - { - "id": 109, - "slug": "rushes-rush-network-and-system-administration-1", - "name": "Rush Network and System Administration #1" - }, - { - "id": 1716, - "slug": "matrice-matrice-concept-car", - "name": "Matrice Concept car" - }, - { - "id": 651, - "slug": "day-09-17", - "name": "17" - }, - { - "id": 476, - "slug": "piscine-c-decloisonnee-pide-jour-09-18", - "name": "18" - }, - { - "id": 451, - "slug": "piscine-c-piadi-jour-09-18", - "name": "18" - }, - { - "id": 201, - "slug": "piscine-c-day-09-17", - "name": "17" - }, - { - "id": 32, - "slug": "rushes-c-minitalk", - "name": "Minitalk" - }, - { - "id": 652, - "slug": "day-09-18", - "name": "18" - }, - { - "id": 477, - "slug": "piscine-c-decloisonnee-pide-jour-09-19", - "name": "19" - }, - { - "id": 452, - "slug": "piscine-c-piadi-jour-09-19", - "name": "19" - }, - { - "id": 407, - "slug": "piscine-c-exam-final", - "name": "Exam Final" - }, - { - "id": 202, - "slug": "piscine-c-day-09-18", - "name": "18" - }, - { - "id": 30, - "slug": "rushes-c-pipex", - "name": "Pipex" - }, - { - "id": 823, - "slug": "42-formation-pole-emploi-rushes-libunit", - "name": "libunit" - }, - { - "id": 653, - "slug": "day-09-19", - "name": "19" - }, - { - "id": 478, - "slug": "piscine-c-decloisonnee-pide-jour-09-20", - "name": "20" - }, - { - "id": 453, - "slug": "piscine-c-piadi-jour-09-20", - "name": "20" - }, - { - "id": 203, - "slug": "piscine-c-day-09-19", - "name": "19" - }, - { - "id": 887, - "slug": "rushes-ft_contrast", - "name": "ft_contrast" - }, - { - "id": 654, - "slug": "day-09-20", - "name": "20" - }, - { - "id": 479, - "slug": "piscine-c-decloisonnee-pide-jour-09-21", - "name": "21" - }, - { - "id": 454, - "slug": "piscine-c-piadi-jour-09-21", - "name": "21" - }, - { - "id": 204, - "slug": "piscine-c-day-09-20", - "name": "20" - }, - { - "id": 1025, - "slug": "rushes-frozen", - "name": "Frozen" - }, - { - "id": 655, - "slug": "day-09-21", - "name": "21" - }, - { - "id": 480, - "slug": "piscine-c-decloisonnee-pide-jour-09-22", - "name": "22" - }, - { - "id": 455, - "slug": "piscine-c-piadi-jour-09-22", - "name": "22" - }, - { - "id": 406, - "slug": "piscine-c-exam02", - "name": "Exam02" - }, - { - "id": 205, - "slug": "piscine-c-day-09-21", - "name": "21" - }, - { - "id": 657, - "slug": "day-09-22", - "name": "22" - }, - { - "id": 538, - "slug": "rushes-ft_minirogue", - "name": "ft_minirogue" - }, - { - "id": 481, - "slug": "piscine-c-decloisonnee-pide-jour-09-23", - "name": "23" - }, - { - "id": 456, - "slug": "piscine-c-piadi-jour-09-23", - "name": "23" - }, - { - "id": 206, - "slug": "piscine-c-day-09-22", - "name": "22" - }, - { - "id": 1095, - "slug": "rushes-numpy", - "name": "Numpy" - }, - { - "id": 658, - "slug": "day-09-23", - "name": "23" - }, - { - "id": 207, - "slug": "piscine-c-day-09-23", - "name": "23" - }, - { - "id": 1112, - "slug": "rushes-ft_tar", - "name": "ft_tar" - }, - { - "id": 404, - "slug": "piscine-c-exam00", - "name": "Exam00" - }, - { - "id": 1163, - "slug": "rushes-ft_pastebin", - "name": "ft_pastebin" - }, - { - "id": 1176, - "slug": "rushes-reverse-engineering", - "name": "Reverse Engineering" - }, - { - "id": 410, - "slug": "bomberman", - "name": "Bomberman" - }, - { - "id": 2110, - "slug": "rushes-wordle", - "name": "Wordle" - }, - { - "id": 411, - "slug": "electronics", - "name": "Electronics" - }, - { - "id": 2121, - "slug": "rushes-abstract-games", - "name": "Abstract Games" - }, - { - "id": 414, - "slug": "ft_linear_regression", - "name": "ft_linear_regression" - }, - { - "id": 2153, - "slug": "rushes-music-collection", - "name": "Music Collection" - }, - { - "id": 519, - "slug": "42partnerships-initiation-web", - "name": "Initiation Web" - }, - { - "id": 2176, - "slug": "rushes-sound-synthesis", - "name": "Sound Synthesis" - }, - { - "id": 506, - "slug": "42partnerships-initiation-ruby", - "name": "Initiation Ruby" - }, - { - "id": 2178, - "slug": "rushes-game-of-life", - "name": "Game of Life" - }, - { - "id": 522, - "slug": "krpsim", - "name": "KrpSim" - }, - { - "id": 523, - "slug": "21sh", - "name": "21sh" - }, - { - "id": 534, - "slug": "rubik", - "name": "Rubik" - }, - { - "id": 535, - "slug": "humangl", - "name": "HumanGL" - }, - { - "id": 536, - "slug": "swifty-companion", - "name": "Swifty Companion" - }, - { - "id": 537, - "slug": "camagru", - "name": "Camagru" - }, - { - "id": 539, - "slug": "ft_ping", - "name": "ft_ping" - }, - { - "id": 540, - "slug": "fillit", - "name": "Fillit" - }, - { - "id": 541, - "slug": "piscine-c-formation-jour-00", - "name": "Jour 00" - }, - { - "id": 542, - "slug": "piscine-c-formation-jour-01", - "name": "Jour 01" - }, - { - "id": 548, - "slug": "ft_traceroute", - "name": "ft_traceroute" - }, - { - "id": 593, - "slug": "ft_nmap", - "name": "ft_nmap" - }, - { - "id": 594, - "slug": "piscine-c-a-distance-libft-old", - "name": "Libft-old" - }, - { - "id": 595, - "slug": "piscine-c-a-distance-fillit", - "name": "Fillit" - }, - { - "id": 596, - "slug": "matcha", - "name": "Matcha" - }, - { - "id": 597, - "slug": "hypertube", - "name": "Hypertube" - }, - { - "id": 601, - "slug": "ft_turing", - "name": "ft_turing" - }, - { - "id": 603, - "slug": "snow-crash", - "name": "Snow Crash" - }, - { - "id": 604, - "slug": "darkly", - "name": "Darkly" - }, - { - "id": 606, - "slug": "bootcamp-day-01", - "name": "Day 01" - }, - { - "id": 608, - "slug": "bootcamp-day-02", - "name": "Day 02" - }, - { - "id": 611, - "slug": "bootcamp-sastantua", - "name": "Sastantua" - }, - { - "id": 612, - "slug": "piscine-c-a-distance-c-exam-training", - "name": "C Exam Training" - }, - { - "id": 616, - "slug": "bootcamp-day-04", - "name": "Day 04" - }, - { - "id": 617, - "slug": "bootcamp-day-05", - "name": "Day 05" - }, - { - "id": 618, - "slug": "bootcamp-day-06", - "name": "Day 06" - }, - { - "id": 620, - "slug": "bootcamp-day-08", - "name": "Day 08" - }, - { - "id": 621, - "slug": "bootcamp-day-07", - "name": "Day 07" - }, - { - "id": 622, - "slug": "bootcamp-match-n-match", - "name": "Match-N-Match" - }, - { - "id": 623, - "slug": "bootcamp-colle-00", - "name": "Colle 00" - }, - { - "id": 624, - "slug": "bootcamp-colle-01", - "name": "Colle 01" - }, - { - "id": 625, - "slug": "bootcamp-day-11", - "name": "Day 11" - }, - { - "id": 626, - "slug": "bootcamp-day-10", - "name": "Day 10" - }, - { - "id": 627, - "slug": "bootcamp-day-12", - "name": "Day 12" - }, - { - "id": 628, - "slug": "bootcamp-day-13", - "name": "Day 13" - }, - { - "id": 629, - "slug": "bootcamp-colle-02", - "name": "Colle 02" - }, - { - "id": 630, - "slug": "bootcamp-evalexpr", - "name": "EvalExpr" - }, - { - "id": 631, - "slug": "bootcamp-bsq", - "name": "BSQ" - }, - { - "id": 633, - "slug": "bootcamp-day-09", - "name": "Day 09" - }, - { - "id": 661, - "slug": "swifty-proteins", - "name": "Swifty Proteins" - }, - { - "id": 665, - "slug": "ft_ality", - "name": "ft_ality" - }, - { - "id": 668, - "slug": "piscine-c-formation-exam06", - "name": "Exam06" - }, - { - "id": 680, - "slug": "bootcamp-wtc-exam-01", - "name": "Bootcamp-WTC-Exam-01" - }, - { - "id": 669, - "slug": "bootcamp-wtc-exam-00", - "name": "Bootcamp-WTC-Exam-00" - }, - { - "id": 670, - "slug": "bootcamp-day-00", - "name": "Day 00" - }, - { - "id": 672, - "slug": "bootcamp-day-03", - "name": "Day 03" - }, - { - "id": 675, - "slug": "formation-pole-emploi-libft-old", - "name": "Libft-old" - }, - { - "id": 677, - "slug": "xv", - "name": "XV" - }, - { - "id": 678, - "slug": "in-the-shadows", - "name": "In the Shadows" - }, - { - "id": 679, - "slug": "particle-system", - "name": "Particle System" - }, - { - "id": 681, - "slug": "bootcamp-wtc-final-exam", - "name": "Bootcamp-WTC-Final-Exam" - }, - { - "id": 683, - "slug": "friends-with-benefits", - "name": "Friends with Benefits" - }, - { - "id": 687, - "slug": "init", - "name": "init" - }, - { - "id": 688, - "slug": "roger-skyline-2", - "name": "roger-skyline-2" - }, - { - "id": 694, - "slug": "cloud-1", - "name": "cloud-1" - }, - { - "id": 695, - "slug": "ft_linux", - "name": "ft_linux" - }, - { - "id": 696, - "slug": "little-penguin-1", - "name": "little-penguin-1" - }, - { - "id": 698, - "slug": "bootcamp-wtc-exam-02", - "name": "Bootcamp-WTC-Exam-02" - }, - { - "id": 699, - "slug": "rainfall", - "name": "RainFall" - }, - { - "id": 700, - "slug": "dr-quine", - "name": "Dr Quine" - }, - { - "id": 701, - "slug": "woody-woodpacker", - "name": "Woody Woodpacker" - }, - { - "id": 702, - "slug": "matt-daemon", - "name": "Matt Daemon" - }, - { - "id": 709, - "slug": "process-and-memory", - "name": "Process and Memory" - }, - { - "id": 710, - "slug": "drivers-and-interrupts", - "name": "Drivers and Interrupts" - }, - { - "id": 711, - "slug": "filesystem", - "name": "Filesystem" - }, - { - "id": 714, - "slug": "kfs-2", - "name": "KFS-2" - }, - { - "id": 716, - "slug": "kfs-1", - "name": "KFS-1" - }, - { - "id": 717, - "slug": "kfs-3", - "name": "KFS-3" - }, - { - "id": 719, - "slug": "music-room", - "name": "Music Room" - }, - { - "id": 727, - "slug": "piscine-python-django", - "name": "Piscine Python Django" - }, - { - "id": 742, - "slug": "piscine-swift-ios", - "name": "Piscine Swift iOS" - }, - { - "id": 756, - "slug": "piscine-reloaded", - "name": "Piscine Reloaded" - }, - { - "id": 791, - "slug": "piscine-ruby-on-rails", - "name": "Piscine Ruby on Rails" - }, - { - "id": 817, - "slug": "42-formation-pole-emploi-42-commandements", - "name": "42 Commandements" - }, - { - "id": 818, - "slug": "red-tetris", - "name": "Red Tetris" - }, - { - "id": 819, - "slug": "h42n42", - "name": "H42N42" - }, - { - "id": 820, - "slug": "famine", - "name": "Famine" - }, - { - "id": 824, - "slug": "kfs-4", - "name": "KFS-4" - }, - { - "id": 825, - "slug": "kfs-5", - "name": "KFS-5" - }, - { - "id": 830, - "slug": "matrice", - "name": "Matrice" - }, - { - "id": 833, - "slug": "hercules", - "name": "Hercules" - }, - { - "id": 846, - "slug": "computorv2", - "name": "ComputorV2" - }, - { - "id": 847, - "slug": "docker-1", - "name": "docker-1" - }, - { - "id": 849, - "slug": "piscine-interview", - "name": "Piscine Interview" - }, - { - "id": 870, - "slug": "avaj-launcher", - "name": "avaj-launcher" - }, - { - "id": 871, - "slug": "swingy", - "name": "swingy" - }, - { - "id": 872, - "slug": "fix-me", - "name": "fix-me" - }, - { - "id": 873, - "slug": "kfs-6", - "name": "KFS-6" - }, - { - "id": 874, - "slug": "kfs-7", - "name": "KFS-7" - }, - { - "id": 876, - "slug": "kfs-8", - "name": "KFS-8" - }, - { - "id": 877, - "slug": "kfs-9", - "name": "KFS-9" - }, - { - "id": 882, - "slug": "kfs-x", - "name": "KFS-X" - }, - { - "id": 888, - "slug": "ft_db", - "name": "ft_db" - }, - { - "id": 891, - "slug": "crea-piscine-after-effects-day-00", - "name": "Piscine After Effects Day 00" - }, - { - "id": 892, - "slug": "crea-piscine-after-effects-day-01", - "name": "Piscine After Effects Day 01" - }, - { - "id": 893, - "slug": "crea-piscine-after-effects-day-02", - "name": "Piscine After Effects Day 02" - }, - { - "id": 895, - "slug": "crea-piscine-after-effects-day-03", - "name": "Piscine After Effects Day 03" - }, - { - "id": 897, - "slug": "crea-piscine-after-effects-day-04", - "name": "Piscine After Effects Day 04" - }, - { - "id": 901, - "slug": "crea-piscine-after-effects-rush-00", - "name": "Piscine After Effects Rush 00" - }, - { - "id": 902, - "slug": "curriculum-vitae", - "name": "Curriculum Vitae" - }, - { - "id": 903, - "slug": "technical-interview-intra-api-interview", - "name": "Intra API Interview" - }, - { - "id": 904, - "slug": "technical-interview-sys-admin-technical-tests", - "name": "Sys admin Technical Tests" - }, - { - "id": 905, - "slug": "pestilence", - "name": "Pestilence" - }, - { - "id": 907, - "slug": "war", - "name": "War" - }, - { - "id": 908, - "slug": "death", - "name": "Death" - }, - { - "id": 909, - "slug": "boot2root", - "name": "Boot2Root" - }, - { - "id": 910, - "slug": "durex", - "name": "Durex" - }, - { - "id": 912, - "slug": "override", - "name": "Override" - }, - { - "id": 914, - "slug": "kift", - "name": "KIFT" - }, - { - "id": 919, - "slug": "x-mansion-x-mansion-namido-d00", - "name": "D00" - }, - { - "id": 920, - "slug": "x-mansion-x-mansion-namido-d01", - "name": "D01" - }, - { - "id": 921, - "slug": "x-mansion-x-mansion-namido-d02", - "name": "D02" - }, - { - "id": 922, - "slug": "x-mansion-x-mansion-namido-d03", - "name": "D03" - }, - { - "id": 923, - "slug": "x-mansion-x-mansion-namido-d04-advanced", - "name": "D04 Advanced" - }, - { - "id": 924, - "slug": "x-mansion-x-mansion-namido-d05", - "name": "D05" - }, - { - "id": 925, - "slug": "x-mansion-x-mansion-namido-d06", - "name": "D06" - }, - { - "id": 926, - "slug": "x-mansion-x-mansion-namido-d07", - "name": "D07" - }, - { - "id": 927, - "slug": "x-mansion-x-mansion-namido-d08", - "name": "D08" - }, - { - "id": 933, - "slug": "x-mansion-x-mansion-namido-d04-basics", - "name": "D04 Basics" - }, - { - "id": 942, - "slug": "42-piscine-c-harassment_policy", - "name": "harassment_policy" - }, - { - "id": 945, - "slug": "reverse-game-of-life", - "name": "Reverse Game of Life" - }, - { - "id": 948, - "slug": "greenlight", - "name": "greenlight" - }, - { - "id": 953, - "slug": "check-your-dorms", - "name": "Check Your Dorms" - }, - { - "id": 960, - "slug": "stairway_to_42", - "name": "Stairway_to_42" - }, - { - "id": 961, - "slug": "wethinkcode_-first-internship", - "name": "First-Internship" - }, - { - "id": 970, - "slug": "wethinkcode_-social-tech-lab", - "name": "Social-Tech-Lab" - }, - { - "id": 977, - "slug": "ft_zenko", - "name": "ft_zenko" - }, - { - "id": 978, - "slug": "ft_vox", - "name": "ft_vox" - }, - { - "id": 980, - "slug": "walking-marvin", - "name": "Walking Marvin" - }, - { - "id": 983, - "slug": "ft_ssl_rsa", - "name": "ft_ssl_rsa" - }, - { - "id": 985, - "slug": "ft_ssl_des", - "name": "ft_ssl_des" - }, - { - "id": 1009, - "slug": "start-here-hello-42", - "name": "START HERE - Hello 42!" - }, - { - "id": 1012, - "slug": "c-exam-alone-in-the-dark-intermediate", - "name": "C Exam Alone In The Dark - Intermediate" - }, - { - "id": 1023, - "slug": "ft_debut", - "name": "ft_debut" - }, - { - "id": 1037, - "slug": "bistromatic", - "name": "Bistromatic" - }, - { - "id": 1049, - "slug": "unit-factory-harassment-tolerance-policy", - "name": "UNIT Factory Harassment & Tolerance Policy" - }, - { - "id": 1052, - "slug": "simplyelectronic", - "name": "SimplyElectronic" - }, - { - "id": 1055, - "slug": "startup-internship", - "name": "Startup-Internship" - }, - { - "id": 1057, - "slug": "blackhole-peer-help", - "name": "Blackhole - Peer Help" - }, - { - "id": 1058, - "slug": "grimly", - "name": "Grimly" - }, - { - "id": 1059, - "slug": "shell-0", - "name": "Shell 0" - }, - { - "id": 1060, - "slug": "shell-1", - "name": "Shell 1" - }, - { - "id": 1061, - "slug": "shell-2", - "name": "Shell 2" - }, - { - "id": 1062, - "slug": "shell-3", - "name": "Shell 3" - }, - { - "id": 1063, - "slug": "ruby-00", - "name": "Ruby 00" - }, - { - "id": 1064, - "slug": "ruby-01", - "name": "Ruby 01" - }, - { - "id": 1065, - "slug": "ruby-02", - "name": "Ruby 02" - }, - { - "id": 1066, - "slug": "ruby-03", - "name": "Ruby 03" - }, - { - "id": 1067, - "slug": "ruby-04", - "name": "Ruby 04" - }, - { - "id": 1068, - "slug": "ruby-05", - "name": "Ruby 05" - }, - { - "id": 1070, - "slug": "ruby-06", - "name": "Ruby 06" - }, - { - "id": 1071, - "slug": "ruby-07", - "name": "Ruby 07" - }, - { - "id": 1072, - "slug": "projet-ruby", - "name": "Projet Ruby" - }, - { - "id": 1073, - "slug": "web-00", - "name": "Web 00" - }, - { - "id": 1074, - "slug": "web-01", - "name": "Web 01" - }, - { - "id": 1075, - "slug": "web-02", - "name": "Web 02" - }, - { - "id": 1076, - "slug": "web-03", - "name": "Web 03" - }, - { - "id": 1077, - "slug": "web-04", - "name": "Web 04" - }, - { - "id": 1078, - "slug": "web-05", - "name": "Web 05" - }, - { - "id": 1079, - "slug": "projet-web", - "name": "Projet Web" - }, - { - "id": 1080, - "slug": "h2s-project-authorship-t2", - "name": "H2S Project Authorship - T2" - }, - { - "id": 1081, - "slug": "dslr", - "name": "DSLR" - }, - { - "id": 1084, - "slug": "ccmn", - "name": "CCMN" - }, - { - "id": 1087, - "slug": "shaderpixel", - "name": "ShaderPixel" - }, - { - "id": 1107, - "slug": "algorithmic-puzzles", - "name": "Algorithmic Puzzles" - }, - { - "id": 1109, - "slug": "hack-your-own-adventure", - "name": "Hack Your Own Adventure" - }, - { - "id": 1111, - "slug": "h2s-project-authorship-t1", - "name": "H2S Project Authorship - T1" - }, - { - "id": 1113, - "slug": "h2s-mentorship-project-auditing", - "name": "H2S Mentorship - Project Auditing" - }, - { - "id": 1117, - "slug": "guimp", - "name": "GUImp" - }, - { - "id": 1118, - "slug": "hackerrank-university-codesprint-4", - "name": "HackerRank University CodeSprint 4" - }, - { - "id": 1119, - "slug": "userspace_digressions", - "name": "userspace_digressions" - }, - { - "id": 1124, - "slug": "netflix-hackathon", - "name": "Netflix Hackathon" - }, - { - "id": 1125, - "slug": "piscine-photoshop-day-00", - "name": "Piscine Photoshop Day 00" - }, - { - "id": 1126, - "slug": "piscine-photoshop-day-01", - "name": "Piscine Photoshop Day 01" - }, - { - "id": 1127, - "slug": "piscine-photoshop-day-02", - "name": "Piscine Photoshop Day 02" - }, - { - "id": 1128, - "slug": "piscine-photoshop-day-03", - "name": "Piscine Photoshop Day 03" - }, - { - "id": 1129, - "slug": "piscine-photoshop-day-04", - "name": "Piscine Photoshop Day 04" - }, - { - "id": 1130, - "slug": "piscine-photoshop-rush-00", - "name": "Piscine Photoshop Rush 00" - }, - { - "id": 1138, - "slug": "atlantis-day-00", - "name": "Atlantis - Day 00" - }, - { - "id": 1139, - "slug": "atlantis-day-01", - "name": "Atlantis - Day 01" - }, - { - "id": 1140, - "slug": "atlantis-day-02", - "name": "Atlantis - Day 02" - }, - { - "id": 1141, - "slug": "python", - "name": "Python" - }, - { - "id": 1147, - "slug": "piscine-php-symfony", - "name": "Piscine PHP Symfony" - }, - { - "id": 1161, - "slug": "atlantis-chatterbot", - "name": "Atlantis - Chatterbot" - }, - { - "id": 1162, - "slug": "multilayer-perceptron", - "name": "Multilayer Perceptron" - }, - { - "id": 1165, - "slug": "ft_sommelier", - "name": "ft_sommelier" - }, - { - "id": 1166, - "slug": "np1", - "name": "NP1" - }, - { - "id": 1175, - "slug": "javascript", - "name": "Javascript" - }, - { - "id": 1182, - "slug": "doom-nukem", - "name": "Doom Nukem" - }, - { - "id": 1183, - "slug": "hackathon-born2hack", - "name": "Hackathon Born2Hack" - }, - { - "id": 1184, - "slug": "teen-idol", - "name": "Teen Idol" - }, - { - "id": 1185, - "slug": "yellow-brick-road", - "name": "Yellow Brick Road" - }, - { - "id": 1189, - "slug": "h2s-project-editor-t1", - "name": "H2S Project Editor T1" - }, - { - "id": 1190, - "slug": "roger-skyline-1", - "name": "roger-skyline-1" - }, - { - "id": 1191, - "slug": "data-mining", - "name": "Data Mining" - }, - { - "id": 1198, - "slug": "dapp-init", - "name": "Dapp-init" - }, - { - "id": 1199, - "slug": "uf_bird", - "name": "uf_bird" - }, - { - "id": 1200, - "slug": "p5js", - "name": "p5JS" - }, - { - "id": 1203, - "slug": "total-perspective-vortex", - "name": "Total-perspective-vortex" - }, - { - "id": 1204, - "slug": "b_libft", - "name": "b_libft" - }, - { - "id": 1205, - "slug": "b_printf", - "name": "b_printf" - }, - { - "id": 1206, - "slug": "b_ls", - "name": "b_ls" - }, - { - "id": 1207, - "slug": "cs-joy", - "name": "CS-Joy" - }, - { - "id": 1208, - "slug": "java", - "name": "Java" - }, - { - "id": 1209, - "slug": "piscine-python-django-day00", - "name": "Piscine Python Django Day00" - }, - { - "id": 1210, - "slug": "piscine-python-django-day01", - "name": "Piscine Python Django Day01" - }, - { - "id": 1211, - "slug": "piscine-python-django-day02", - "name": "Piscine Python Django Day02" - }, - { - "id": 1212, - "slug": "piscine-python-django-day03", - "name": "Piscine Python Django Day03" - }, - { - "id": 1213, - "slug": "piscine-python-django-day04", - "name": "Piscine Python Django Day04" - }, - { - "id": 1214, - "slug": "piscine-python-django-rush-00", - "name": "piscine-python-django-rush-00" - }, - { - "id": 1215, - "slug": "piscine-python-django-day05", - "name": "Piscine Python Django Day05" - }, - { - "id": 1216, - "slug": "piscine-python-django-day06", - "name": "Piscine Python Django Day06" - }, - { - "id": 1217, - "slug": "piscine-python-django-day07", - "name": "Piscine Python Django Day07" - }, - { - "id": 1218, - "slug": "piscine-python-django-day08", - "name": "Piscine Python Django Day08" - }, - { - "id": 1219, - "slug": "day-09", - "name": "Day 09" - }, - { - "id": 1220, - "slug": "piscine-python-django-rush-01", - "name": "Piscine Python Django Rush 01" - }, - { - "id": 1227, - "slug": "blackhole-peer-helper", - "name": "Blackhole - Peer Helper" - }, - { - "id": 1228, - "slug": "plagiart", - "name": "Plagiart" - }, - { - "id": 1230, - "slug": "wildcard", - "name": "Wildcard" - }, - { - "id": 1237, - "slug": "hackhighschool-mentorship-program", - "name": "HackHighSchool Mentorship Program" - }, - { - "id": 1238, - "slug": "linkedin", - "name": "LinkedIn" - }, - { - "id": 1239, - "slug": "go-programming", - "name": "Go Programming" - }, - { - "id": 1241, - "slug": "piscine-illustrator-day-00", - "name": "Piscine Illustrator Day 00" - }, - { - "id": 1242, - "slug": "piscine-illustrator-day-01", - "name": "Piscine Illustrator Day 01" - }, - { - "id": 1243, - "slug": "piscine-illustrator-day-02", - "name": "Piscine Illustrator Day 02" - }, - { - "id": 1244, - "slug": "piscine-illustrator-day-04", - "name": "Piscine Illustrator Day 04" - }, - { - "id": 1245, - "slug": "piscine-illustrator-day-03", - "name": "Piscine Illustrator Day 03" - }, - { - "id": 1246, - "slug": "piscine-illustrator-rush00", - "name": "Piscine Illustrator Rush00" - }, - { - "id": 1255, - "slug": "c-piscine-shell-00", - "name": "C Piscine Shell 00" - }, - { - "id": 1256, - "slug": "c-piscine-shell-01", - "name": "C Piscine Shell 01" - }, - { - "id": 1257, - "slug": "c-piscine-c-00", - "name": "C Piscine C 00" - }, - { - "id": 1258, - "slug": "c-piscine-c-01", - "name": "C Piscine C 01" - }, - { - "id": 1259, - "slug": "c-piscine-c-02", - "name": "C Piscine C 02" - }, - { - "id": 1260, - "slug": "c-piscine-c-03", - "name": "C Piscine C 03" - }, - { - "id": 1261, - "slug": "c-piscine-c-04", - "name": "C Piscine C 04" - }, - { - "id": 1262, - "slug": "c-piscine-c-05", - "name": "C Piscine C 05" - }, - { - "id": 1263, - "slug": "c-piscine-c-06", - "name": "C Piscine C 06" - }, - { - "id": 1264, - "slug": "c-piscine-c-08", - "name": "C Piscine C 08" - }, - { - "id": 1265, - "slug": "c-piscine-c-09", - "name": "C Piscine C 09" - }, - { - "id": 1266, - "slug": "c-piscine-c-10", - "name": "C Piscine C 10" - }, - { - "id": 1267, - "slug": "c-piscine-c-11", - "name": "C Piscine C 11" - }, - { - "id": 1268, - "slug": "c-piscine-c-12", - "name": "C Piscine C 12" - }, - { - "id": 1270, - "slug": "c-piscine-c-07", - "name": "C Piscine C 07" - }, - { - "id": 1271, - "slug": "c-piscine-c-13", - "name": "C Piscine C 13" - }, - { - "id": 1283, - "slug": "machine-learning", - "name": "Machine Learning" - }, - { - "id": 1285, - "slug": "h2s-project-editor-t2", - "name": "H2S Project Editor T2" - }, - { - "id": 1291, - "slug": "pygame", - "name": "Pygame" - }, - { - "id": 1295, - "slug": "node-js", - "name": "Node.js" - }, - { - "id": 1299, - "slug": "42gui", - "name": "42GUI" - }, - { - "id": 1301, - "slug": "c-piscine-exam-00", - "name": "C Piscine Exam 00" - }, - { - "id": 1302, - "slug": "c-piscine-exam-01", - "name": "C Piscine Exam 01" - }, - { - "id": 1303, - "slug": "c-piscine-exam-02", - "name": "C Piscine Exam 02" - }, - { - "id": 1304, - "slug": "c-piscine-final-exam", - "name": "C Piscine Final Exam" - }, - { - "id": 1305, - "slug": "c-piscine-bsq", - "name": "C Piscine BSQ" - }, - { - "id": 1306, - "slug": "genesis-b", - "name": "Genesis B" - }, - { - "id": 1308, - "slug": "c-piscine-rush-00", - "name": "C Piscine Rush 00" - }, - { - "id": 1309, - "slug": "c-piscine-rush-02", - "name": "C Piscine Rush 02" - }, - { - "id": 1310, - "slug": "c-piscine-rush-01", - "name": "C Piscine Rush 01" - }, - { - "id": 1314, - "slug": "42cursus-libft", - "name": "Libft" - }, - { - "id": 1315, - "slug": "minirt", - "name": "miniRT" - }, - { - "id": 1316, - "slug": "42cursus-ft_printf", - "name": "ft_printf" - }, - { - "id": 1320, - "slug": "exam-rank-02", - "name": "Exam Rank 02" - }, - { - "id": 1321, - "slug": "exam-rank-03", - "name": "Exam Rank 03" - }, - { - "id": 1322, - "slug": "exam-rank-04", - "name": "Exam Rank 04" - }, - { - "id": 1323, - "slug": "exam-rank-05", - "name": "Exam Rank 05" - }, - { - "id": 1324, - "slug": "exam-rank-06", - "name": "Exam Rank 06" - }, - { - "id": 1326, - "slug": "cub3d", - "name": "cub3d" - }, - { - "id": 1327, - "slug": "42cursus-get_next_line", - "name": "get_next_line" - }, - { - "id": 1328, - "slug": "ft_server", - "name": "ft_server" - }, - { - "id": 1329, - "slug": "ft_services", - "name": "ft_services" - }, - { - "id": 1330, - "slug": "libasm", - "name": "libasm" - }, - { - "id": 1331, - "slug": "42cursus-minishell", - "name": "minishell" - }, - { - "id": 1332, - "slug": "webserv", - "name": "webserv" - }, - { - "id": 1334, - "slug": "42cursus-philosophers", - "name": "Philosophers" - }, - { - "id": 1335, - "slug": "ft_containers", - "name": "ft_containers" - }, - { - "id": 1336, - "slug": "ft_irc", - "name": "ft_irc" - }, - { - "id": 1337, - "slug": "ft_transcendence", - "name": "ft_transcendence" - }, - { - "id": 1347, - "slug": "d00-html", - "name": "D00 - HTML" - }, - { - "id": 1348, - "slug": "d01-css", - "name": "D01 - CSS" - }, - { - "id": 1349, - "slug": "d03-javascript", - "name": "D03 - Javascript" - }, - { - "id": 1350, - "slug": "d04-advanced-javascript", - "name": "D04 - Advanced Javascript" - }, - { - "id": 1351, - "slug": "d02-css-js", - "name": "D02 - CSS/JS" - }, - { - "id": 1352, - "slug": "rush", - "name": "RUSH" - }, - { - "id": 1353, - "slug": "data-structures", - "name": "Data Structures" - }, - { - "id": 1361, - "slug": "apcsp-prep", - "name": "APCSP Prep" - }, - { - "id": 1372, - "slug": "python-101-d00", - "name": "Python-101 D00" - }, - { - "id": 1373, - "slug": "python-101-d01", - "name": "Python-101 D01" - }, - { - "id": 1374, - "slug": "python-101-d02", - "name": "Python-101 D02" - }, - { - "id": 1376, - "slug": "python-101-d03", - "name": "Python-101 D03" - }, - { - "id": 1377, - "slug": "python-101-d04", - "name": "Python-101 D04" - }, - { - "id": 1378, - "slug": "python-101-rush", - "name": "Python-101 Rush" - }, - { - "id": 1379, - "slug": "42cursus-ft_hangouts", - "name": "ft_hangouts" - }, - { - "id": 1381, - "slug": "42cursus-taskmaster", - "name": "taskmaster" - }, - { - "id": 1382, - "slug": "42cursus-computorv1", - "name": "computorv1" - }, - { - "id": 1383, - "slug": "42cursus-gomoku", - "name": "gomoku" - }, - { - "id": 1384, - "slug": "42cursus-expert-system", - "name": "expert-system" - }, - { - "id": 1385, - "slug": "42cursus-n-puzzle", - "name": "n-puzzle" - }, - { - "id": 1386, - "slug": "42cursus-nibbler", - "name": "nibbler" - }, - { - "id": 1387, - "slug": "42cursus-42run", - "name": "42run" - }, - { - "id": 1388, - "slug": "42cursus-strace", - "name": "strace" - }, - { - "id": 1389, - "slug": "42cursus-bomberman", - "name": "bomberman" - }, - { - "id": 1390, - "slug": "42cursus-scop", - "name": "scop" - }, - { - "id": 1391, - "slug": "42cursus-ft_linear_regression", - "name": "ft_linear_regression" - }, - { - "id": 1392, - "slug": "42cursus-krpsim", - "name": "krpsim" - }, - { - "id": 1393, - "slug": "42cursus-rubik", - "name": "rubik" - }, - { - "id": 1394, - "slug": "42cursus-humangl", - "name": "humangl" - }, - { - "id": 1395, - "slug": "42cursus-swifty-companion", - "name": "swifty-companion" - }, - { - "id": 1396, - "slug": "42cursus-camagru", - "name": "camagru" - }, - { - "id": 1397, - "slug": "42cursus-ft_ping", - "name": "ft_ping" - }, - { - "id": 1399, - "slug": "42cursus-ft_traceroute", - "name": "ft_traceroute" - }, - { - "id": 1400, - "slug": "42cursus-ft_nmap", - "name": "ft_nmap" - }, - { - "id": 1401, - "slug": "42cursus-matcha", - "name": "matcha" - }, - { - "id": 1402, - "slug": "42cursus-hypertube", - "name": "hypertube" - }, - { - "id": 1403, - "slug": "42cursus-ft_turing", - "name": "ft_turing" - }, - { - "id": 1404, - "slug": "42cursus-snow-crash", - "name": "snow-crash" - }, - { - "id": 1405, - "slug": "42cursus-darkly", - "name": "darkly" - }, - { - "id": 1406, - "slug": "42cursus-swifty-proteins", - "name": "swifty-proteins" - }, - { - "id": 1407, - "slug": "42cursus-ft_ality", - "name": "ft_ality" - }, - { - "id": 1408, - "slug": "42cursus-xv", - "name": "xv" - }, - { - "id": 1409, - "slug": "42cursus-in-the-shadows", - "name": "in-the-shadows" - }, - { - "id": 1410, - "slug": "42cursus-particle-system", - "name": "particle-system" - }, - { - "id": 1411, - "slug": "42cursus-gbmu", - "name": "gbmu" - }, - { - "id": 1414, - "slug": "42cursus-cloud-1", - "name": "cloud-1" - }, - { - "id": 1415, - "slug": "42cursus-ft_linux", - "name": "ft_linux" - }, - { - "id": 1416, - "slug": "42cursus-little-penguin-1", - "name": "little-penguin-1" - }, - { - "id": 1417, - "slug": "42cursus-rainfall", - "name": "rainfall" - }, - { - "id": 1418, - "slug": "42cursus-dr-quine", - "name": "dr-quine" - }, - { - "id": 1419, - "slug": "42cursus-woody-woodpacker", - "name": "woody-woodpacker" - }, - { - "id": 1420, - "slug": "42cursus-matt-daemon", - "name": "matt-daemon" - }, - { - "id": 1421, - "slug": "42cursus-process-and-memory", - "name": "process-and-memory" - }, - { - "id": 1422, - "slug": "42cursus-drivers-and-interrupts", - "name": "drivers-and-interrupts" - }, - { - "id": 1423, - "slug": "42cursus-filesystem", - "name": "filesystem" - }, - { - "id": 1424, - "slug": "42cursus-kfs-2", - "name": "kfs-2" - }, - { - "id": 1425, - "slug": "42cursus-kfs-1", - "name": "kfs-1" - }, - { - "id": 1426, - "slug": "42cursus-kfs-3", - "name": "kfs-3" - }, - { - "id": 1427, - "slug": "42cursus-music-room", - "name": "music-room" - }, - { - "id": 1428, - "slug": "42cursus-red-tetris", - "name": "red-tetris" - }, - { - "id": 1429, - "slug": "42cursus-h42n42", - "name": "h42n42" - }, - { - "id": 1430, - "slug": "42cursus-famine", - "name": "famine" - }, - { - "id": 1431, - "slug": "42cursus-kfs-4", - "name": "kfs-4" - }, - { - "id": 1432, - "slug": "42cursus-kfs-5", - "name": "kfs-5" - }, - { - "id": 1433, - "slug": "42cursus-computorv2", - "name": "computorv2" - }, - { - "id": 1435, - "slug": "42cursus-avaj-launcher", - "name": "avaj-launcher" - }, - { - "id": 1436, - "slug": "42cursus-swingy", - "name": "swingy" - }, - { - "id": 1437, - "slug": "42cursus-fix-me", - "name": "fix-me" - }, - { - "id": 1438, - "slug": "42cursus-kfs-6", - "name": "kfs-6" - }, - { - "id": 1439, - "slug": "42cursus-kfs-7", - "name": "kfs-7" - }, - { - "id": 1440, - "slug": "42cursus-kfs-8", - "name": "kfs-8" - }, - { - "id": 1441, - "slug": "42cursus-kfs-9", - "name": "kfs-9" - }, - { - "id": 1442, - "slug": "42cursus-kfs-x", - "name": "kfs-x" - }, - { - "id": 1443, - "slug": "42cursus-pestilence", - "name": "pestilence" - }, - { - "id": 1444, - "slug": "42cursus-war", - "name": "war" - }, - { - "id": 1445, - "slug": "42cursus-death", - "name": "death" - }, - { - "id": 1446, - "slug": "42cursus-boot2root", - "name": "boot2root" - }, - { - "id": 1447, - "slug": "42cursus-ft_shield", - "name": "ft_shield" - }, - { - "id": 1448, - "slug": "42cursus-override", - "name": "override" - }, - { - "id": 1449, - "slug": "42cursus-ft_vox", - "name": "ft_vox" - }, - { - "id": 1450, - "slug": "42cursus-ft_ssl_rsa", - "name": "ft_ssl_rsa" - }, - { - "id": 1451, - "slug": "42cursus-ft_ssl_md5", - "name": "ft_ssl_md5" - }, - { - "id": 1452, - "slug": "42cursus-ft_ssl_des", - "name": "ft_ssl_des" - }, - { - "id": 1453, - "slug": "42cursus-dslr", - "name": "dslr" - }, - { - "id": 1454, - "slug": "42cursus-shaderpixel", - "name": "shaderpixel" - }, - { - "id": 1455, - "slug": "42cursus-guimp", - "name": "guimp" - }, - { - "id": 1456, - "slug": "42cursus-userspace_digressions", - "name": "userspace_digressions" - }, - { - "id": 1457, - "slug": "42cursus-multilayer-perceptron", - "name": "multilayer-perceptron" - }, - { - "id": 1458, - "slug": "42cursus-doom-nukem", - "name": "doom-nukem" - }, - { - "id": 1460, - "slug": "42cursus-total-perspective-vortex", - "name": "total-perspective-vortex" - }, - { - "id": 1461, - "slug": "42cursus-abstract-vm", - "name": "abstract-vm" - }, - { - "id": 1462, - "slug": "42cursus-mod1", - "name": "mod1" - }, - { - "id": 1463, - "slug": "42cursus-zappy", - "name": "zappy" - }, - { - "id": 1464, - "slug": "42cursus-lem-ipc", - "name": "lem-ipc" - }, - { - "id": 1466, - "slug": "42cursus-ft_script", - "name": "ft_script" - }, - { - "id": 1467, - "slug": "nm", - "name": "nm" - }, - { - "id": 1468, - "slug": "42cursus-malloc", - "name": "malloc" - }, - { - "id": 1469, - "slug": "42cursus-ft_select", - "name": "ft_select" - }, - { - "id": 1470, - "slug": "42cursus-lem_in", - "name": "lem_in" - }, - { - "id": 1471, - "slug": "42cursus-push_swap", - "name": "push_swap" - }, - { - "id": 1475, - "slug": "42cursus-corewar", - "name": "corewar" - }, - { - "id": 1476, - "slug": "42cursus-fract-ol", - "name": "fract-ol" - }, - { - "id": 1479, - "slug": "42cursus-ft_ls", - "name": "ft_ls" - }, - { - "id": 1480, - "slug": "eu-aceito", - "name": "Eu aceito" - }, - { - "id": 1481, - "slug": "42cursus-piscine-php-symfony", - "name": "Piscine PHP Symfony" - }, - { - "id": 1482, - "slug": "42cursus-piscine-ruby-on-rails", - "name": "Piscine Ruby on Rails" - }, - { - "id": 1483, - "slug": "deprecated-piscine-python-django", - "name": "[DEPRECATED] Piscine Python Django" - }, - { - "id": 1484, - "slug": "deprecated-piscine-ocaml", - "name": "[DEPRECATED] Piscine OCaml" - }, - { - "id": 1485, - "slug": "deprecated-piscine-unity", - "name": "[DEPRECATED] Piscine Unity" - }, - { - "id": 1486, - "slug": "deprecated-piscine-swift-ios", - "name": "[DEPRECATED] Piscine Swift iOS" - }, - { - "id": 1338, - "slug": "cpp-module-00", - "name": "CPP Module 00" - }, - { - "id": 1339, - "slug": "cpp-module-01", - "name": "CPP Module 01" - }, - { - "id": 1340, - "slug": "cpp-module-02", - "name": "CPP Module 02" - }, - { - "id": 1341, - "slug": "cpp-module-03", - "name": "CPP Module 03" - }, - { - "id": 1342, - "slug": "cpp-module-04", - "name": "CPP Module 04" - }, - { - "id": 1343, - "slug": "cpp-module-05", - "name": "CPP Module 05" - }, - { - "id": 1344, - "slug": "cpp-module-06", - "name": "CPP Module 06" - }, - { - "id": 1345, - "slug": "cpp-module-07", - "name": "CPP Module 07" - }, - { - "id": 1346, - "slug": "cpp-module-08", - "name": "CPP Module 08" - }, - { - "id": 1633, - "slug": "linkedin-profile-task", - "name": "LinkedIn Profile Task" - }, - { - "id": 1635, - "slug": "open-project", - "name": "Open Project" - }, - { - "id": 1638, - "slug": "work-experience-i", - "name": "Work Experience I" - }, - { - "id": 1644, - "slug": "work-experience-ii", - "name": "Work Experience II" - }, - { - "id": 1650, - "slug": "part_time-i", - "name": "Part_Time I" - }, - { - "id": 1656, - "slug": "part_time-ii", - "name": "Part_Time II" - }, - { - "id": 1662, - "slug": "42cursus-startup-experience", - "name": "Startup Experience" - }, - { - "id": 1673, - "slug": "electronics-old", - "name": "Electronics-Old" - }, - { - "id": 1674, - "slug": "discovery-pedagogy", - "name": "Discovery-Pedagogy" - }, - { - "id": 1676, - "slug": "day00-html", - "name": "Day00 - HTML" - }, - { - "id": 1677, - "slug": "day01-css", - "name": "Day01 - CSS" - }, - { - "id": 1678, - "slug": "day02-js", - "name": "Day02 - JS" - }, - { - "id": 1679, - "slug": "rush-final", - "name": "Rush final" - }, - { - "id": 1683, - "slug": "old-philosophers", - "name": "Old-Philosophers" - }, - { - "id": 1684, - "slug": "old-irc", - "name": "Old-IRC" - }, - { - "id": 1685, - "slug": "deprecated-old-cpp-module-00", - "name": "[DEPRECATED] Old-CPP Module 00" - }, - { - "id": 1686, - "slug": "deprecated-old-cpp-module-01", - "name": "[DEPRECATED] Old-CPP Module 01" - }, - { - "id": 1687, - "slug": "deprecated-old-cpp-module-02", - "name": "[DEPRECATED] Old-CPP Module 02" - }, - { - "id": 1688, - "slug": "deprecated-old-cpp-module-03", - "name": "[DEPRECATED] Old-CPP Module 03" - }, - { - "id": 1689, - "slug": "deprecated-old-cpp-module-04", - "name": "[DEPRECATED] Old-CPP Module 04" - }, - { - "id": 1690, - "slug": "deprecated-old-cpp-module-05", - "name": "[DEPRECATED] Old-CPP Module 05" - }, - { - "id": 1691, - "slug": "deprecated-old-cpp-module-06", - "name": "[DEPRECATED] Old-CPP Module 06" - }, - { - "id": 1692, - "slug": "deprecated-old-cpp-module-07", - "name": "[DEPRECATED] Old-CPP Module 07" - }, - { - "id": 1693, - "slug": "deprecated-old-cpp-module-08", - "name": "[DEPRECATED] Old-CPP Module 08" - }, - { - "id": 1668, - "slug": "deprecated-apcsp_00", - "name": "[Deprecated]APCSP_00" - }, - { - "id": 1733, - "slug": "iotapp", - "name": "IoTApp" - }, - { - "id": 1758, - "slug": "pre-open-01", - "name": "Pre Open 01" - }, - { - "id": 1759, - "slug": "pre-open-00", - "name": "Pre Open 00" - }, - { - "id": 1760, - "slug": "pre-open-02", - "name": "Pre Open 02" - }, - { - "id": 1761, - "slug": "42-seoul-ex1", - "name": "42 seoul ex1" - }, - { - "id": 1762, - "slug": "42-seoul-my-little-tv", - "name": "42 seoul my little tv" - }, - { - "id": 1764, - "slug": "42-squads", - "name": "42 Squads" - }, - { - "id": 1786, - "slug": "deprecated-piscine-python-data-science", - "name": "[DEPRECATED] Piscine Python Data Science" - }, - { - "id": 1799, - "slug": "piscine-java", - "name": "Piscine Java" - }, - { - "id": 1814, - "slug": "darkly-web", - "name": "darkly - web" - }, - { - "id": 1816, - "slug": "module-00-ds-test", - "name": "Module 00 DS test" - }, - { - "id": 1817, - "slug": "basecamp-shell-00", - "name": "Basecamp Shell 00" - }, - { - "id": 1818, - "slug": "basecamp-shell-01", - "name": "Basecamp Shell 01" - }, - { - "id": 1819, - "slug": "basecamp-eu-aceito", - "name": "Basecamp Eu Aceito" - }, - { - "id": 1820, - "slug": "basecamp-c-00", - "name": "Basecamp C 00" - }, - { - "id": 1821, - "slug": "basecamp-c-01", - "name": "Basecamp C 01" - }, - { - "id": 1822, - "slug": "basecamp-c-02", - "name": "Basecamp C 02" - }, - { - "id": 1823, - "slug": "basecamp-c-03", - "name": "Basecamp C 03" - }, - { - "id": 1824, - "slug": "basecamp-c-04", - "name": "Basecamp C 04" - }, - { - "id": 1825, - "slug": "basecamp-c-05", - "name": "Basecamp C 05" - }, - { - "id": 1826, - "slug": "basecamp-c-06", - "name": "Basecamp C 06" - }, - { - "id": 1827, - "slug": "basecamp-c-07", - "name": "Basecamp C 07" - }, - { - "id": 1828, - "slug": "basecamp-c-08", - "name": "Basecamp C 08" - }, - { - "id": 1829, - "slug": "basecamp-c-09", - "name": "Basecamp C 09" - }, - { - "id": 1830, - "slug": "basecamp-c-10", - "name": "Basecamp C 10" - }, - { - "id": 1831, - "slug": "basecamp-c-11", - "name": "Basecamp C 11" - }, - { - "id": 1832, - "slug": "basecamp-c-12", - "name": "Basecamp C 12" - }, - { - "id": 1833, - "slug": "basecamp-c-13", - "name": "Basecamp C 13" - }, - { - "id": 1834, - "slug": "basecamp-rush-00", - "name": "Basecamp Rush 00" - }, - { - "id": 1835, - "slug": "basecamp-rush-01", - "name": "Basecamp Rush 01" - }, - { - "id": 1837, - "slug": "basecamp-exam-00", - "name": "Basecamp Exam 00" - }, - { - "id": 1838, - "slug": "basecamp-exam-01", - "name": "Basecamp Exam 01" - }, - { - "id": 1839, - "slug": "basecamp-final-exam", - "name": "Basecamp Final Exam" - }, - { - "id": 1840, - "slug": "ft_malcolm", - "name": "ft_malcolm" - }, - { - "id": 1848, - "slug": "electronique", - "name": "Electronique" - }, - { - "id": 1853, - "slug": "doom_nukem", - "name": "doom_nukem" - }, - { - "id": 1854, - "slug": "42cursus-42sh", - "name": "42sh" - }, - { - "id": 1855, - "slug": "42cursus-rt", - "name": "rt" - }, - { - "id": 1857, - "slug": "42cursus-apprentissage-2-ans-1ere-annee", - "name": "Apprentissage 2 ans - 1ère année" - }, - { - "id": 1865, - "slug": "apprentissage-2-ans-2eme-annee", - "name": " Apprentissage 2 ans - 2ème année" - }, - { - "id": 1873, - "slug": "apprentissage-1-an", - "name": "Apprentissage 1 an" - }, - { - "id": 1881, - "slug": "ft_mini_ls", - "name": "ft_mini_ls" - }, - { - "id": 1882, - "slug": "hello_node", - "name": "hello_node" - }, - { - "id": 1883, - "slug": "freddie-mercury", - "name": "freddie-mercury" - }, - { - "id": 1884, - "slug": "hello_vue", - "name": "hello_vue" - }, - { - "id": 1885, - "slug": "pokedex_vue", - "name": "pokedex_vue" - }, - { - "id": 1886, - "slug": "test-bassecamp-c-00", - "name": "test-bassecamp-c-00" - }, - { - "id": 1887, - "slug": "test-basecamp-i-accept", - "name": "test-basecamp-i-accept" - }, - { - "id": 1888, - "slug": "test-basecamp-shell-00", - "name": "test-basecamp-shell-00" - }, - { - "id": 1889, - "slug": "germany-basecamp-i-accept-old", - "name": "Germany Basecamp I Accept (OLD)" - }, - { - "id": 1890, - "slug": "germany-basecamp-germany-basecamp-shell-00", - "name": "Germany Basecamp Shell 00" - }, - { - "id": 1891, - "slug": "germany-basecamp-shell-01", - "name": "Germany Basecamp Shell 01" - }, - { - "id": 1892, - "slug": "germany-basecamp-c-00", - "name": "Germany Basecamp C 00" - }, - { - "id": 1893, - "slug": "germany-basecamp-c-01", - "name": "Germany Basecamp C 01" - }, - { - "id": 1894, - "slug": "germany-basecamp-c-02", - "name": "Germany Basecamp C 02" - }, - { - "id": 1895, - "slug": "germany-basecamp-c-03", - "name": "Germany Basecamp C 03" - }, - { - "id": 1896, - "slug": "germany-basecamp-c-04", - "name": "Germany Basecamp C 04" - }, - { - "id": 1897, - "slug": "germany-basecamp-c-05", - "name": "Germany Basecamp C 05" - }, - { - "id": 1898, - "slug": "germany-basecamp-c-06", - "name": "Germany Basecamp C 06" - }, - { - "id": 1899, - "slug": "germany-basecamp-c-07", - "name": "Germany Basecamp C 07" - }, - { - "id": 1900, - "slug": "germany-basecamp-c-08", - "name": "Germany Basecamp C 08" - }, - { - "id": 1901, - "slug": "germany-basecamp-c-09", - "name": "Germany Basecamp C 09" - }, - { - "id": 1903, - "slug": "germany-basecamp-c-10", - "name": "Germany Basecamp C 10" - }, - { - "id": 1904, - "slug": "germany-basecamp-c-11", - "name": "Germany Basecamp C 11" - }, - { - "id": 1905, - "slug": "germany-basecamp-c-12", - "name": "Germany Basecamp C 12" - }, - { - "id": 1906, - "slug": "germany-basecamp-c-13", - "name": "Germany Basecamp C 13" - }, - { - "id": 1907, - "slug": "germany-basecamp-rush-00", - "name": "Germany Basecamp Rush 00" - }, - { - "id": 1908, - "slug": "germany-basecamp-rush-01", - "name": "Germany Basecamp Rush 01" - }, - { - "id": 1910, - "slug": "germany-basecamp-exam-01", - "name": "Germany Basecamp Exam 01" - }, - { - "id": 1911, - "slug": "germany-basecamp-final-exam", - "name": "Germany Basecamp Final Exam" - }, - { - "id": 1912, - "slug": "brussels-basecamp-shell-00", - "name": "Brussels Basecamp Shell 00" - }, - { - "id": 1913, - "slug": "brussels-basecamp-shell-01", - "name": "Brussels Basecamp Shell 01" - }, - { - "id": 1914, - "slug": "brussels-basecamp-c-00", - "name": "Brussels Basecamp C 00" - }, - { - "id": 1915, - "slug": "brussels-basecamp-c-01", - "name": "Brussels Basecamp C 01" - }, - { - "id": 1916, - "slug": "brussels-basecamp-c-09", - "name": "Brussels Basecamp C 09" - }, - { - "id": 1918, - "slug": "brussels-basecamp-c-03", - "name": "Brussels Basecamp C 03" - }, - { - "id": 1917, - "slug": "brussels-basecamp-c-02", - "name": "Brussels Basecamp C 02" - }, - { - "id": 1920, - "slug": "brussels-basecamp-c-05", - "name": "Brussels Basecamp C 05" - }, - { - "id": 1919, - "slug": "brussels-basecamp-c-04", - "name": "Brussels Basecamp C 04" - }, - { - "id": 1921, - "slug": "brussels-basecamp-c-06", - "name": "Brussels Basecamp C 06" - }, - { - "id": 1922, - "slug": "brussels-basecamp-c-07", - "name": "Brussels Basecamp C 07" - }, - { - "id": 1923, - "slug": "brussels-basecamp-c-08", - "name": "Brussels Basecamp C 08" - }, - { - "id": 1924, - "slug": "brussels-basecamp-rush-00", - "name": "Brussels Basecamp Rush 00" - }, - { - "id": 1925, - "slug": "brussels-basecamp-rush-01", - "name": "Brussels Basecamp Rush 01" - }, - { - "id": 1926, - "slug": "brussels-basecamp-exam-00", - "name": "Brussels Basecamp Exam 00" - }, - { - "id": 1927, - "slug": "brussels-basecamp-exam-01", - "name": "Brussels Basecamp Exam 01" - }, - { - "id": 1928, - "slug": "brussels-basecamp-final-exam", - "name": "Brussels Basecamp Final Exam" - }, - { - "id": 1929, - "slug": "brussels-basecamp-c-10", - "name": "Brussels Basecamp C 10" - }, - { - "id": 1930, - "slug": "brussels-basecamp-c-11", - "name": "Brussels Basecamp C 11" - }, - { - "id": 1931, - "slug": "brussels-basecamp-c-12", - "name": "Brussels Basecamp C 12" - }, - { - "id": 1932, - "slug": "brussels-basecamp-c-13", - "name": "Brussels Basecamp C 13" - }, - { - "id": 1933, - "slug": "tweets", - "name": "tweets" - }, - { - "id": 1934, - "slug": "churn", - "name": "churn" - }, - { - "id": 1936, - "slug": "brussels-basecamp-i-accept", - "name": "Brussels Basecamp I Accept" - }, - { - "id": 1937, - "slug": "brussels-basecamp-intro-git", - "name": "Brussels Basecamp Intro Git" - }, - { - "id": 1938, - "slug": "piscine-101-shell-00", - "name": "Piscine 101 Shell 00" - }, - { - "id": 1943, - "slug": "myspotify", - "name": "mySpotify" - }, - { - "id": 1945, - "slug": "fwa", - "name": "FWA" - }, - { - "id": 1946, - "slug": "restful", - "name": "Restful" - }, - { - "id": 1947, - "slug": "microservices", - "name": "MicroServices" - }, - { - "id": 1948, - "slug": "piscine-101-python-02", - "name": "Piscine 101 Python 02" - }, - { - "id": 1949, - "slug": "piscine-101-python-04", - "name": "Piscine 101 Python 04" - }, - { - "id": 1950, - "slug": "piscine-101-python-rush", - "name": "Piscine 101 Python Rush" - }, - { - "id": 1951, - "slug": "cinema", - "name": "Cinema" - }, - { - "id": 1952, - "slug": "springboot", - "name": "SpringBoot" - }, - { - "id": 1953, - "slug": "piscine-101-python-01", - "name": "Piscine 101 Python 01" - }, - { - "id": 1954, - "slug": "piscine-101-python-03", - "name": "Piscine 101 Python 03" - }, - { - "id": 1955, - "slug": "uber", - "name": "Uber" - }, - { - "id": 1956, - "slug": "understanding-customer", - "name": " Understanding customer" - }, - { - "id": 1957, - "slug": "city-life", - "name": "City Life" - }, - { - "id": 1958, - "slug": "messagequeue", - "name": "MessageQueue" - }, - { - "id": 1959, - "slug": "amazon", - "name": "Amazon" - }, - { - "id": 1960, - "slug": "fried-eggs", - "name": "Fried eggs" - }, - { - "id": 1962, - "slug": "ft_newton", - "name": "ft_newton" - }, - { - "id": 1963, - "slug": "libunit", - "name": "libunit" - }, - { - "id": 1964, - "slug": "hive-basecamp-day-00", - "name": "Hive Basecamp - Day 00" - }, - { - "id": 1965, - "slug": "hive-basecamp-day-01", - "name": "Hive Basecamp - Day 01" - }, - { - "id": 1966, - "slug": "hive-basecamp-day-02", - "name": "Hive Basecamp - Day 02" - }, - { - "id": 1967, - "slug": "hive-basecamp-day-03", - "name": "Hive Basecamp - Day 03" - }, - { - "id": 1968, - "slug": "hive-basecamp-day-04", - "name": "Hive Basecamp - Day 04" - }, - { - "id": 1969, - "slug": "hive-basecamp-day-05", - "name": "Hive Basecamp - Day 05" - }, - { - "id": 1970, - "slug": "hive-basecamp-day-06", - "name": "Hive Basecamp - Day 06" - }, - { - "id": 1971, - "slug": "hive-basecamp-day-07", - "name": "Hive Basecamp - Day 07" - }, - { - "id": 1972, - "slug": "hive-basecamp-day-08", - "name": "Hive Basecamp - Day 08" - }, - { - "id": 1973, - "slug": "hive-basecamp-day-10", - "name": "Hive Basecamp - Day 10" - }, - { - "id": 1974, - "slug": "hive-basecamp-day-11", - "name": "Hive Basecamp - Day 11" - }, - { - "id": 1975, - "slug": "hive-basecamp-exam-00", - "name": "Hive Basecamp - Exam 00" - }, - { - "id": 1976, - "slug": "hive-basecamp-exam-01", - "name": "Hive Basecamp - Exam 01" - }, - { - "id": 1977, - "slug": "hive-basecamp-final-exam", - "name": "Hive Basecamp - Final exam" - }, - { - "id": 1978, - "slug": "hive-basecamp-rush-00", - "name": "Hive Basecamp - Rush 00" - }, - { - "id": 1979, - "slug": "hive-basecamp-rush-01", - "name": "Hive Basecamp - Rush 01" - }, - { - "id": 1980, - "slug": "hive-basecamp-sastantua", - "name": "Hive Basecamp - Sastantua" - }, - { - "id": 1981, - "slug": "hive-basecamp-match-n-match", - "name": "Hive Basecamp - Match-N-Match" - }, - { - "id": 1983, - "slug": "inception", - "name": "Inception" - }, - { - "id": 1984, - "slug": "deprecated-python-module-00", - "name": "[DEPRECATED] Python Module 00" - }, - { - "id": 1986, - "slug": "go-piscine-go-00-advanced", - "name": "Go Piscine Go 00 Advanced" - }, - { - "id": 1987, - "slug": "hive-basecamp-day-13", - "name": "Hive Basecamp - Day 13" - }, - { - "id": 1988, - "slug": "hive-basecamp-day-12", - "name": "Hive Basecamp - Day 12" - }, - { - "id": 1989, - "slug": "hive-basecamp-exam-02", - "name": "Hive Basecamp - Exam 02" - }, - { - "id": 1990, - "slug": "hive-basecamp-evalexpr", - "name": "Hive Basecamp - EvalExpr" - }, - { - "id": 1991, - "slug": "hive-basecamp-rush-02", - "name": "Hive Basecamp - Rush 02" - }, - { - "id": 1992, - "slug": "hive-basecamp-bsq", - "name": "Hive Basecamp - BSQ" - }, - { - "id": 1993, - "slug": "seoul-labs-member", - "name": "SEOUL LABS MEMBER" - }, - { - "id": 1994, - "slug": "born2beroot", - "name": "Born2beroot" - }, - { - "id": 1996, - "slug": "deprecated-python-module-02", - "name": "[DEPRECATED] Python Module 02" - }, - { - "id": 1997, - "slug": "deprecated-python-module-03", - "name": "[DEPRECATED] Python Module 03" - }, - { - "id": 1998, - "slug": "deprecated-python-module-04", - "name": "[DEPRECATED] Python Module 04" - }, - { - "id": 1999, - "slug": "deprecated-ml-module-00", - "name": "[DEPRECATED] ML Module 00" - }, - { - "id": 2000, - "slug": "deprecated-ml-module-01", - "name": "[DEPRECATED] ML Module 01" - }, - { - "id": 2001, - "slug": "deprecated-ml-module-02", - "name": "[DEPRECATED] ML Module 02" - }, - { - "id": 2002, - "slug": "deprecated-ml-module-03", - "name": "[DEPRECATED] ML Module 03" - }, - { - "id": 2003, - "slug": "deprecated-ml-module-04", - "name": "[DEPRECATED] ML Module 04" - }, - { - "id": 2004, - "slug": "pipex", - "name": "pipex" - }, - { - "id": 2005, - "slug": "minitalk", - "name": "minitalk" - }, - { - "id": 2006, - "slug": "42seoul_test", - "name": "42SEOUL_TEST" - }, - { - "id": 2007, - "slug": "netpractice", - "name": "NetPractice" - }, - { - "id": 2008, - "slug": "42cursus-fdf", - "name": "FdF" - }, - { - "id": 2009, - "slug": "so_long", - "name": "so_long" - }, - { - "id": 2010, - "slug": "road-to-mercari-gopher-dojo-00", - "name": "Road-to-Mercari-Gopher-Dojo-00" - }, - { - "id": 2012, - "slug": "cellule0-0-shell", - "name": "Cellule0.0 - Shell" - }, - { - "id": 2014, - "slug": "cellule0-2-shell", - "name": "Cellule0.2 - Shell" - }, - { - "id": 2015, - "slug": "cellule0-3-shell", - "name": "Cellule0.3 - Shell" - }, - { - "id": 2016, - "slug": "cellule0-4-shell", - "name": "Cellule0.4 - Shell" - }, - { - "id": 2017, - "slug": "cellule0-5-shell", - "name": "Cellule0.5 - Shell" - }, - { - "id": 2018, - "slug": "cellule1-0-web", - "name": "Cellule1.0 - Web" - }, - { - "id": 2019, - "slug": "cellule1-1-web", - "name": "Cellule1.1 - Web" - }, - { - "id": 2020, - "slug": "cellule1-2-web", - "name": "Cellule1.2 - Web" - }, - { - "id": 2021, - "slug": "cellule1-3-web", - "name": "Cellule1.3 - Web" - }, - { - "id": 2022, - "slug": "cellule1-4-web", - "name": "Cellule1.4 - Web" - }, - { - "id": 2023, - "slug": "cellule1-5-web", - "name": "Cellule1.5 - Web" - }, - { - "id": 2024, - "slug": "cellule1-6-web", - "name": "Cellule1.6 - Web" - }, - { - "id": 2025, - "slug": "cellule2-0-web", - "name": "Cellule2.0 - Web" - }, - { - "id": 2026, - "slug": "cellule2-1-web", - "name": "Cellule2.1 - Web" - }, - { - "id": 2027, - "slug": "cellule2-2-web", - "name": "Cellule2.2 - Web" - }, - { - "id": 2028, - "slug": "cellule2-3-web", - "name": "Cellule2.3 - Web" - }, - { - "id": 2029, - "slug": "cellule3-0-web", - "name": "Cellule3.0 - Web" - }, - { - "id": 2030, - "slug": "cellule3-1-web", - "name": "Cellule3.1 - Web" - }, - { - "id": 2031, - "slug": "cellule3-2-web", - "name": "Cellule3.2 - Web" - }, - { - "id": 2032, - "slug": "cellule3-3-web", - "name": "Cellule3.3 - Web" - }, - { - "id": 2033, - "slug": "cellule3-4-web", - "name": "Cellule3.4 - Web" - }, - { - "id": 2034, - "slug": "cellule4-0-rush", - "name": "Cellule4.0 - Rush" - }, - { - "id": 2035, - "slug": "road-to-mercari-gopher-dojo-02", - "name": "Road-to-Mercari-Gopher-Dojo-02" - }, - { - "id": 2037, - "slug": "road-to-mercari-gopher-dojo-01", - "name": "Road-to-Mercari-Gopher-Dojo-01" - }, - { - "id": 2041, - "slug": "c-01", - "name": "C 01" - }, - { - "id": 2042, - "slug": "c-00", - "name": "C 00" - }, - { - "id": 2043, - "slug": "c-02", - "name": "C 02" - }, - { - "id": 2044, - "slug": "road-to-mercari-gopher-dojo-03", - "name": "Road-to-Mercari-Gopher-Dojo-03" - }, - { - "id": 2046, - "slug": "ft_communication", - "name": "ft_communication" - }, - { - "id": 2047, - "slug": "refactor_bsq", - "name": "refactor_bsq" - }, - { - "id": 2049, - "slug": "shell-00", - "name": "SHELL 00" - }, - { - "id": 2050, - "slug": "shell-01", - "name": "SHELL 01" - }, - { - "id": 2051, - "slug": "basecamp-warm-up-rio-eu-aceito", - "name": "Eu Aceito!" - }, - { - "id": 2052, - "slug": "c-04", - "name": "C 04" - }, - { - "id": 2053, - "slug": "c-05", - "name": "C 05" - }, - { - "id": 2054, - "slug": "c-06", - "name": "C 06" - }, - { - "id": 2055, - "slug": "rush-00", - "name": "RUSH 00" - }, - { - "id": 2056, - "slug": "rush-01", - "name": "RUSH 01" - }, - { - "id": 2057, - "slug": "c-07", - "name": "C 07" - }, - { - "id": 2058, - "slug": "c-08", - "name": "C 08" - }, - { - "id": 2059, - "slug": "c-09", - "name": "C 09" - }, - { - "id": 2060, - "slug": "c-10", - "name": "C 10" - }, - { - "id": 2061, - "slug": "c-11", - "name": "C 11" - }, - { - "id": 2062, - "slug": "c-12", - "name": "C 12" - }, - { - "id": 2063, - "slug": "c-13", - "name": "C 13" - }, - { - "id": 2064, - "slug": "inception-of-things", - "name": "Inception-of-Things" - }, - { - "id": 2065, - "slug": "42cursus-rushes", - "name": "Rushes" - }, - { - "id": 2068, - "slug": "cow-neck-tid", - "name": "Cow-Neck-TID" - }, - { - "id": 2071, - "slug": "bgp-at-doors-of-autonomous-systems-is-simple", - "name": " Bgp At Doors of Autonomous Systems is Simple" - }, - { - "id": 2072, - "slug": "exam-42-zip", - "name": "Exam 42 Zip" - }, - { - "id": 2073, - "slug": "ft_communication_v2", - "name": "ft_communication_v2" - }, - { - "id": 2074, - "slug": "road-to-mercari-gopher-dojoo-01", - "name": "Road-to-Mercari-Gopher-Dojoo-01" - }, - { - "id": 2075, - "slug": "road-to-dmm-bootcamp-go", - "name": "Road-to-DMM-Bootcamp-Go" - }, - { - "id": 2076, - "slug": "ready-set-boole", - "name": "ready set boole" - }, - { - "id": 2077, - "slug": "matrix", - "name": "matrix" - }, - { - "id": 2078, - "slug": "wolfsburg-i-accept", - "name": "Wolfsburg I Accept" - }, - { - "id": 2082, - "slug": "germany-basecamp-i-accept", - "name": "Germany Basecamp I Accept" - }, - { - "id": 2085, - "slug": "basecamp-rio-eu-aceito", - "name": "Eu Aceito!!" - }, - { - "id": 2088, - "slug": "ft_self-analysis", - "name": "ft_self-analysis" - }, - { - "id": 2097, - "slug": "tinky-winkey", - "name": "tinky-winkey" - }, - { - "id": 1909, - "slug": "germany-basecamp-exam-00", - "name": "Germany Basecamp Exam 00" - }, - { - "id": 2098, - "slug": "ft_kalman", - "name": "ft_kalman" - }, - { - "id": 2099, - "slug": "term3d", - "name": "term3d" - }, - { - "id": 2100, - "slug": "road-to-mixi-mini-sns-00", - "name": "Road-to-MIXI-Mini-SNS-00" - }, - { - "id": 2101, - "slug": "road-to-mixi-mini-sns-01", - "name": "Road-to-MIXI-Mini-SNS-01" - }, - { - "id": 2103, - "slug": "ft_helpme", - "name": "ft_helpme" - }, - { - "id": 2104, - "slug": "libft-00", - "name": "Libft-00" - }, - { - "id": 2105, - "slug": "libft-01", - "name": "Libft-01" - }, - { - "id": 2106, - "slug": "libft-02", - "name": "Libft-02" - }, - { - "id": 2107, - "slug": "road-to-cyberagent-ca-tech-dojo-go", - "name": "Road-to-CyberAgent-CA-Tech-Dojo-Go" - }, - { - "id": 2108, - "slug": "libft-03", - "name": "Libft-03" - }, - { - "id": 2109, - "slug": "libft-04", - "name": "Libft-04" - }, - { - "id": 2112, - "slug": "ft_ssl_md5", - "name": "ft_ssl_md5" - }, - { - "id": 2113, - "slug": "ft_newton42", - "name": "ft_newton42" - }, - { - "id": 2114, - "slug": "ft_abstract_vm", - "name": "ft_abstract_vm" - }, - { - "id": 2115, - "slug": "42_matrix", - "name": "42_matrix" - }, - { - "id": 2116, - "slug": "42-ready-set-boole", - "name": "42 Ready Set boole" - }, - { - "id": 2117, - "slug": "ft_malcolm_42", - "name": "ft_malcolm_42" - }, - { - "id": 2118, - "slug": "42_ft_shield", - "name": "42_ft_shield" - }, - { - "id": 2124, - "slug": "piscine_reloaded", - "name": "Piscine_Reloaded" - }, - { - "id": 2126, - "slug": "ft_minecraft", - "name": "ft_minecraft" - }, - { - "id": 2135, - "slug": "go-piscine-go-00", - "name": "Go Piscine Go 00" - }, - { - "id": 2137, - "slug": "go-piscine-go-01", - "name": "Go Piscine Go 01" - }, - { - "id": 2138, - "slug": "go-piscine-go-02", - "name": "Go Piscine Go 02" - }, - { - "id": 2139, - "slug": "go-piscine-go-03", - "name": "Go Piscine Go 03" - }, - { - "id": 2140, - "slug": "go-piscine-go-04", - "name": "Go Piscine Go 04" - }, - { - "id": 2141, - "slug": "go-piscine-go-05", - "name": "Go Piscine Go 05" - }, - { - "id": 2142, - "slug": "go-piscine-go-06", - "name": "Go Piscine Go 06" - }, - { - "id": 2143, - "slug": "go-piscine-go-07", - "name": "Go Piscine Go 07" - }, - { - "id": 2144, - "slug": "go-piscine-go-08", - "name": "Go Piscine Go 08" - }, - { - "id": 2145, - "slug": "go-piscine-go-09", - "name": "Go Piscine Go 09" - }, - { - "id": 2146, - "slug": "go-piscine-go-10", - "name": "Go Piscine Go 10" - }, - { - "id": 2148, - "slug": "go-piscine-rush-00", - "name": "Go Piscine Rush 00" - }, - { - "id": 2149, - "slug": "go-piscine-rush-01", - "name": "Go Piscine Rush 01" - }, - { - "id": 2150, - "slug": "go-piscine-rush-02", - "name": "Go Piscine Rush 02" - }, - { - "id": 2151, - "slug": "go-piscine-go-bsq", - "name": "Go Piscine Go BSQ" - }, - { - "id": 2175, - "slug": "sql-workshop", - "name": "Sql-workshop" - }, - { - "id": 2013, - "slug": "cellule0-1-shell", - "name": "Cellule0.1 - Shell" - }, - { - "id": 2177, - "slug": "i-accept", - "name": "I accept" - }, - { - "id": 2179, - "slug": "piscine-ror", - "name": "Piscine RoR" - }, - { - "id": 2189, - "slug": "piscine-django", - "name": "Piscine Django" - }, - { - "id": 2199, - "slug": "piscine-symfony", - "name": "Piscine Symfony" - }, - { - "id": 2209, - "slug": "refactor_tetris", - "name": "refactor_tetris" - }, - { - "id": 2212, - "slug": "codam_exam_test", - "name": "codam_exam_test" - }, - { - "id": 2217, - "slug": "cursus-eu-aceito-rio", - "name": "Cursus Eu Aceito Rio" - }, - { - "id": 2218, - "slug": "p2p-101", - "name": "P2P 101" - }, - { - "id": 2225, - "slug": "ftl_quantum", - "name": "Ftl_quantum" - }, - { - "id": 2162, - "slug": "ft_onion", - "name": "ft_onion" - }, - { - "id": 2171, - "slug": "corsair", - "name": "coRSAir" - }, - { - "id": 2159, - "slug": "ft_blockchain", - "name": "ft_blockchain" - }, - { - "id": 2166, - "slug": "tsunami", - "name": "tsunami" - }, - { - "id": 2157, - "slug": "arachnida", - "name": "arachnida" - }, - { - "id": 2163, - "slug": "ft_otp", - "name": "ft_otp" - }, - { - "id": 2169, - "slug": "recovery", - "name": "recovery" - }, - { - "id": 2168, - "slug": "extraction", - "name": "extraction" - }, - { - "id": 2167, - "slug": "iron_dome", - "name": "iron_dome" - }, - { - "id": 2165, - "slug": "vaccine", - "name": "vaccine" - }, - { - "id": 2164, - "slug": "inquisitor", - "name": "inquisitor" - }, - { - "id": 2172, - "slug": "stockholm", - "name": "stockholm" - }, - { - "id": 2238, - "slug": "exam_test_42", - "name": "exam_test_42" - }, - { - "id": 2239, - "slug": "hive-internship", - "name": "Hive Internship" - }, - { - "id": 2245, - "slug": "hive-startup-internship", - "name": "Hive Startup Internship" - }, - { - "id": 2262, - "slug": "angular-piscine-week-1-day-01", - "name": "Angular Piscine Week 1 | Day 01" - }, - { - "id": 2263, - "slug": "3sc4p3", - "name": "3sc4p3" - }, - { - "id": 2267, - "slug": "python-for-data-science", - "name": "Python for Data Science" - }, - { - "id": 2268, - "slug": "python-0-starting", - "name": "Python - 0 - Starting" - }, - { - "id": 2269, - "slug": "python-1-array", - "name": "Python - 1 - Array" - }, - { - "id": 2270, - "slug": "python-2-datatable", - "name": "Python - 2 - DataTable" - }, - { - "id": 2271, - "slug": "python-3-oop", - "name": "Python - 3 - OOP" - }, - { - "id": 2272, - "slug": "python-4-dod", - "name": "Python - 4 - Dod" - }, - { - "id": 2284, - "slug": "unity-1-3d-physics-tags-layers-and-scene", - "name": "Unity - 1 - 3D physics, Tags, Layers and Scene" - }, - { - "id": 2285, - "slug": "unity-6-navmesh-light-sound-and-camera", - "name": "Unity - 6 - Navmesh, light, sound and camera" - }, - { - "id": 2286, - "slug": "unity-0-the-basics-unity-tools", - "name": "Unity - 0 - The basics Unity tools" - }, - { - "id": 2287, - "slug": "unity-5-singleton-playerprefs-and-coroutines", - "name": "Unity - 5 - Singleton, playerPrefs and coroutines" - }, - { - "id": 2288, - "slug": "unity", - "name": "Unity" - }, - { - "id": 2289, - "slug": "unity-2-2d-environment-tiles-and-sprites", - "name": "Unity - 2 - 2D environment, tiles and sprites" - }, - { - "id": 2290, - "slug": "unity-3-advanced-inputs-and-2d-gui", - "name": "Unity - 3 - Advanced inputs and 2D GUI" - }, - { - "id": 2292, - "slug": "unity-4-animations-and-sound", - "name": "Unity - 4 - Animations and Sound" - }, - { - "id": 2295, - "slug": "piscine-data-science", - "name": "Piscine Data Science" - }, - { - "id": 2306, - "slug": "data-science-0", - "name": "Data Science - 0" - }, - { - "id": 2309, - "slug": "cpp-module-09", - "name": "CPP Module 09" - }, - { - "id": 2310, - "slug": "42cursus-alone-in-the-dark", - "name": "Alone in the Dark" - }, - { - "id": 2311, - "slug": "data-science-2", - "name": "Data Science - 2" - }, - { - "id": 2294, - "slug": "mini-piscine-python-django-day-00", - "name": "Mini-Piscine Python Django Day 00" - }, - { - "id": 2297, - "slug": "mini-piscine-python-django-day-01", - "name": "Mini-Piscine Python Django Day 01" - }, - { - "id": 2298, - "slug": "mini-piscine-python-django-day-02", - "name": "Mini-Piscine Python Django Day 02" - }, - { - "id": 2299, - "slug": "mini-piscine-python-django-day-03", - "name": "Mini-Piscine Python Django Day 03" - }, - { - "id": 2300, - "slug": "mini-piscine-python-django-day-04", - "name": "Mini-Piscine Python Django Day 04" - }, - { - "id": 2301, - "slug": "mini-piscine-python-django-day-05", - "name": "Mini-Piscine Python Django Day 05" - }, - { - "id": 2302, - "slug": "mini-piscine-python-django-day-06", - "name": "Mini-Piscine Python Django Day 06" - }, - { - "id": 2303, - "slug": "mini-piscine-python-django-day-07", - "name": "Mini-Piscine Python Django Day 07" - }, - { - "id": 2304, - "slug": "mini-piscine-python-django-day-08", - "name": "Mini-Piscine Python Django Day 08" - }, - { - "id": 2305, - "slug": "mini-piscine-python-django-day-09", - "name": "Mini-Piscine Python Django Day 09" - }, - { - "id": 2180, - "slug": "ror-0-initiation", - "name": "RoR - 0 - Initiation" - }, - { - "id": 2181, - "slug": "ror-0-starting", - "name": " RoR - 0 - Starting" - }, - { - "id": 2182, - "slug": "ror-0-oob", - "name": "RoR - 0 - Oob" - }, - { - "id": 2183, - "slug": "ror-1-gems", - "name": "RoR - 1 - Gems" - }, - { - "id": 2184, - "slug": "ror-1-base-rails", - "name": "RoR - 1 - Base Rails" - }, - { - "id": 2185, - "slug": "ror-2-sql", - "name": "RoR - 2 - SQL" - }, - { - "id": 2186, - "slug": "ror-3-sessions", - "name": "RoR - 3 - Sessions" - }, - { - "id": 2187, - "slug": "ror-3-advanced", - "name": "RoR - 3 - Advanced" - }, - { - "id": 2188, - "slug": "ror-3-final", - "name": "RoR - 3 - Final" - }, - { - "id": 2200, - "slug": "symfony-0-initiation", - "name": "Symfony - 0 - Initiation" - }, - { - "id": 2201, - "slug": "symfony-0-starting", - "name": "Symfony - 0 - Starting" - }, - { - "id": 2202, - "slug": "symfony-0-oob", - "name": "Symfony - 0 - Oob" - }, - { - "id": 2203, - "slug": "symfony-1-composer", - "name": "Symfony - 1 - Composer" - }, - { - "id": 2204, - "slug": "symfony-1-base-symfony", - "name": "Symfony - 1 - Base Symfony " - }, - { - "id": 2205, - "slug": "symfony-2-sql", - "name": "Symfony - 2 - SQL" - }, - { - "id": 2206, - "slug": "symfony-3-sessions", - "name": "Symfony - 3 - Sessions" - }, - { - "id": 2207, - "slug": "symfony-3-advanced", - "name": "Symfony - 3 - Advanced" - }, - { - "id": 2208, - "slug": "symfony-3-final", - "name": "Symfony - 3 - Final" - }, - { - "id": 2190, - "slug": "django-0-initiation", - "name": "Django - 0 - Initiation" - }, - { - "id": 2191, - "slug": "django-0-starting", - "name": "Django - 0 - Starting" - }, - { - "id": 2192, - "slug": "django-0-oob", - "name": "Django - 0 - Oob" - }, - { - "id": 2193, - "slug": "django-1-lib", - "name": "Django - 1 - Lib" - }, - { - "id": 2194, - "slug": "django-1-base-django", - "name": "Django - 1 - Base Django" - }, - { - "id": 2195, - "slug": "django-2-sql", - "name": "Django - 2 - SQL" - }, - { - "id": 2196, - "slug": "django-3-sessions", - "name": "Django - 3 - Sessions" - }, - { - "id": 2197, - "slug": "django-3-advanced", - "name": "Django - 3 - Advanced" - }, - { - "id": 2198, - "slug": "django-3-final", - "name": "Django - 3 - Final" - }, - { - "id": 2315, - "slug": "c-piscine-reloaded", - "name": "C-piscine-reloaded" - }, - { - "id": 2316, - "slug": "data-science-3", - "name": "Data Science - 3" - }, - { - "id": 2317, - "slug": "data-science-4", - "name": "Data Science - 4" - }, - { - "id": 2318, - "slug": "data-science-1", - "name": "Data Science - 1" - }, - { - "id": 2319, - "slug": "tweet", - "name": "tweet" - }, - { - "id": 2320, - "slug": "java-module-00", - "name": "Java Module 00" - }, - { - "id": 2321, - "slug": "java-module-01", - "name": "Java Module 01" - }, - { - "id": 2322, - "slug": "java-module-02", - "name": "Java Module 02" - }, - { - "id": 2323, - "slug": "microshell-00", - "name": "microshell-00" - }, - { - "id": 2324, - "slug": "microshell-01", - "name": "microshell-01" - }, - { - "id": 2325, - "slug": "microshell-02", - "name": "microshell-02" - }, - { - "id": 2326, - "slug": "cybersecurity-arachnida-web", - "name": "Cybersecurity - arachnida - Web" - }, - { - "id": 2327, - "slug": "cybersecurity-ft_otp-otp", - "name": "Cybersecurity - ft_otp - OTP" - }, - { - "id": 2328, - "slug": "cybersecurity-ft_onion-web", - "name": "Cybersecurity - ft_onion - Web" - }, - { - "id": 2329, - "slug": "java-module-03", - "name": "Java Module 03" - }, - { - "id": 2330, - "slug": "java-module-04", - "name": "Java Module 04" - }, - { - "id": 2331, - "slug": "java-module-05", - "name": "Java Module 05" - }, - { - "id": 2332, - "slug": "java-module-06", - "name": "Java Module 06" - }, - { - "id": 2333, - "slug": "java-module-07", - "name": "Java Module 07" - }, - { - "id": 2334, - "slug": "java-module-08", - "name": "Java Module 08" - }, - { - "id": 2335, - "slug": "java-module-09", - "name": "Java Module 09" - }, - { - "id": 2336, - "slug": "cybersecurity-reverse-me-rev", - "name": "Cybersecurity - Reverse me - Rev" - }, - { - "id": 2337, - "slug": "cybersecurity-stockholm-malware", - "name": " Cybersecurity - Stockholm - Malware" - }, - { - "id": 2338, - "slug": "fr-apprentissage-rncp-6-1-an", - "name": "FR Apprentissage RNCP 6 - 1 an" - }, - { - "id": 2339, - "slug": "fr-apprentissage-rncp-6-2-ans", - "name": "FR Apprentissage RNCP 6 - 2 ans" - }, - { - "id": 2340, - "slug": "fr-apprentissage-rncp-7-1-an", - "name": "FR Apprentissage RNCP 7 - 1 an" - }, - { - "id": 2341, - "slug": "fr-apprentissage-rncp-7-2-ans", - "name": "FR Apprentissage RNCP 7 - 2 ans" - }, - { - "id": 2343, - "slug": "cybersecurity-inquisitor-network", - "name": "Cybersecurity - Inquisitor - Network" - }, - { - "id": 2344, - "slug": "optional-cybersecurity-iron-dome-malware", - "name": "(Optional) Cybersecurity - Iron Dome - Malware" - }, - { - "id": 2345, - "slug": "cybersecurity-vaccine-web", - "name": "Cybersecurity - Vaccine - Web" - }, - { - "id": 2346, - "slug": "cybersecurity", - "name": "Cybersecurity" - }, - { - "id": 2347, - "slug": "42cursus-fwa", - "name": "fwa" - }, - { - "id": 2348, - "slug": "event-april_2023", - "name": "[Event]April_2023" - }, - { - "id": 2349, - "slug": "42cursus-cinema", - "name": "cinema" - }, - { - "id": 2350, - "slug": "spring-boot", - "name": "Spring Boot" - }, - { - "id": 2351, - "slug": "42cursus-restful", - "name": "restful" - }, - { - "id": 2353, - "slug": "42cursus-messagequeue", - "name": "messagequeue" - }, - { - "id": 2354, - "slug": "mobile-0-basic-of-the-mobile-application", - "name": "Mobile - 0 - Basic of the mobile application" - }, - { - "id": 2355, - "slug": "mobile", - "name": "Mobile" - }, - { - "id": 2356, - "slug": "mobile-1-structure-and-logic", - "name": "Mobile - 1 - Structure and logic" - }, - { - "id": 2357, - "slug": "mobile-2-api-and-data", - "name": "Mobile - 2 - API and data" - }, - { - "id": 2358, - "slug": "mobile-3-design", - "name": "Mobile - 3 - Design" - }, - { - "id": 2359, - "slug": "mobile-4-auth-and-database", - "name": "Mobile - 4 - Auth and dataBase" - }, - { - "id": 2360, - "slug": "mobile-5-manage-data-and-display", - "name": "Mobile - 5 - Manage data and display" - }, - { - "id": 2364, - "slug": "piscine-object", - "name": "Piscine Object" - }, - { - "id": 2365, - "slug": "piscine-object-module-00-encapsulation", - "name": "Piscine Object - Module 00 - Encapsulation" - }, - { - "id": 2366, - "slug": "piscine-object-module-01-relationship", - "name": "Piscine Object - Module 01 - Relationship" - }, - { - "id": 2367, - "slug": "piscine-object-module-02-uml", - "name": "Piscine Object - Module 02 - UML" - }, - { - "id": 2368, - "slug": "piscine-object-module-03-smart", - "name": "Piscine Object - Module 03 - SMART" - }, - { - "id": 2369, - "slug": "piscine-object-module-04-design-pattern", - "name": "Piscine Object - Module 04 - Design Pattern" - }, - { - "id": 2370, - "slug": "piscine-object-module-05-practical-work", - "name": "Piscine Object - Module 05 - Practical work" - }, - { - "id": 1995, - "slug": "deprecated-python-module-01", - "name": "[DEPRECATED] Python Module 01" - }, - { - "id": 2371, - "slug": "abstract_data", - "name": "Abstract_data" - }, - { - "id": 2372, - "slug": "leaffliction", - "name": "Leaffliction" - }, - { - "id": 2373, - "slug": "microshop", - "name": "Microshop" - }, - { - "id": 2374, - "slug": "exam_test_43", - "name": "exam_test_43" - }, - { - "id": 2376, - "slug": "cellule2-0-gecko", - "name": "Cellule2-0-gecko" - }, - { - "id": 2377, - "slug": "cellule2-1-gecko", - "name": "Cellule2-1-gecko " - }, - { - "id": 2378, - "slug": "cellule2-2-gecko", - "name": "Cellule2-2-gecko " - }, - { - "id": 2379, - "slug": "cellule2-3-gecko", - "name": "Cellule2-3-gecko " - }, - { - "id": 2384, - "slug": "cellule0-0-tyto", - "name": "Cellule0-0-Tyto" - }, - { - "id": 2385, - "slug": "cellule0-1-tyto", - "name": "Cellule0-1-Tyto" - }, - { - "id": 2387, - "slug": "cellule0-2-tyto", - "name": "Cellule0-2-Tyto" - }, - { - "id": 2388, - "slug": "cellule0-3-tyto", - "name": "Cellule0-3-Tyto" - }, - { - "id": 2389, - "slug": "cellule1-0-weasel", - "name": "Cellule1-0-weasel" - }, - { - "id": 2390, - "slug": "cellule1-1-weasel", - "name": "Cellule1-1-weasel" - }, - { - "id": 2391, - "slug": "cellule1-2-weasel", - "name": "Cellule1-2-weasel" - }, - { - "id": 2392, - "slug": "cellule1-3-weasel", - "name": "Cellule1-3-weasel" - }, - { - "id": 2393, - "slug": "unleashthebox", - "name": "UnleashTheBox" - }, - { - "id": 2394, - "slug": "ft_microservices", - "name": "ft_microservices" - }, - { - "id": 2396, - "slug": "eu-aceito-porto", - "name": "Eu Aceito Porto" - }, - { - "id": 2397, - "slug": "eu-aceito-lisboa", - "name": "Eu Aceito Lisboa" - }, - { - "id": 2411, - "slug": "leonardo-java-day-00", - "name": "Leonardo Java day-00" - }, - { - "id": 2412, - "slug": "leonardo-java-day-01", - "name": "Leonardo Java day-01" - }, - { - "id": 2413, - "slug": "leonardo-java-day-02", - "name": "Leonardo Java day-02" - }, - { - "id": 2414, - "slug": "leonardo-java-day-03", - "name": "Leonardo Java day-03" - }, - { - "id": 2415, - "slug": "leonardo-java-day-04", - "name": "Leonardo Java day-04" - }, - { - "id": 2416, - "slug": "leonardo-java-day-05", - "name": "Leonardo Java day-05" - }, - { - "id": 2417, - "slug": "leonardo-java-day-06", - "name": "Leonardo Java day-06" - }, - { - "id": 2418, - "slug": "leonardo-java-day-07", - "name": "Leonardo Java day-07" - }, - { - "id": 2419, - "slug": "leonardo-java-day-08", - "name": "Leonardo Java day-08" - }, - { - "id": 2420, - "slug": "leonardo-java-day-09", - "name": "Leonardo Java day-09" - }, - { - "id": 2421, - "slug": "leonardo-java-rush-00", - "name": "Leonardo Java Rush-00" - }, - { - "id": 2422, - "slug": "leonardo-java-rush-01", - "name": "Leonardo Java Rush-01" - }, - { - "id": 2471, - "slug": "angular-piscine-week-1-day-00", - "name": "Angular Piscine Week 1 | Day 00" - }, - { - "id": 2472, - "slug": "angular-piscine-week-1-day-02", - "name": "Angular Piscine Week 1 | Day 02" - }, - { - "id": 2473, - "slug": "angular-piscine-week-1-day-03", - "name": "Angular Piscine Week 1 | Day 03" - }, - { - "id": 2474, - "slug": "angular-piscine-week-1-day-04", - "name": "Angular Piscine Week 1 | Day 04" - }, - { - "id": 2475, - "slug": "angular-piscine-week-1-rush-00", - "name": "Angular Piscine Week 1 | Rush 00" - }, - { - "id": 2476, - "slug": "angular-piscine-week-2-day-00", - "name": "Angular Piscine Week 2 | Day 00" - }, - { - "id": 2477, - "slug": "angular-piscine-week-2-day-01", - "name": "Angular Piscine Week 2 | Day 01" - }, - { - "id": 2478, - "slug": "angular-piscine-week-2-day-02", - "name": "Angular Piscine Week 2 | Day 02" - }, - { - "id": 2479, - "slug": "angular-piscine-week-2-day-03", - "name": "Angular Piscine Week 2 | Day 03" - }, - { - "id": 2480, - "slug": "angular-piscine-week-2-day-04", - "name": "Angular Piscine Week 2 | Day 04" - }, - { - "id": 2481, - "slug": "angular-piscine-week-2-rush-01", - "name": "Angular Piscine Week 2 | Rush-01" - }, - { - "id": 2484, - "slug": "dirbato-00", - "name": "Dirbato 00" - }, - { - "id": 2485, - "slug": "tokenizer", - "name": "Tokenizer" - }, - { - "id": 2486, - "slug": "mini-piscine-mobile-day-00", - "name": "Mini-Piscine Mobile Day 00" - }, - { - "id": 2487, - "slug": "mini-piscine-mobile-day-01", - "name": " Mini-Piscine Mobile Day 01" - }, - { - "id": 2488, - "slug": "mini-piscine-mobile-day-02", - "name": "Mini-Piscine Mobile Day 02" - }, - { - "id": 2489, - "slug": "mini-piscine-mobile-day-03", - "name": "Mini-Piscine Mobile Day 03" - }, - { - "id": 2490, - "slug": "mini-piscine-mobile-day-04", - "name": " Mini-Piscine Mobile Day 04" - }, - { - "id": 2491, - "slug": "mini-piscine-mobile-day-05", - "name": " Mini-Piscine Mobile Day 05" - }, - { - "id": 2494, - "slug": "dirbato-01", - "name": "Dirbato 01" - }, - { - "id": 2503, - "slug": "codam-startup-internship", - "name": "Codam Startup Internship" - }, - { - "id": 80, - "slug": "abstract-vm", - "name": "Abstract VM" - }, - { - "id": 79, - "slug": "libftasm", - "name": "LibftASM" - }, - { - "id": 78, - "slug": "mod1", - "name": "mod1" - }, - { - "id": 62, - "slug": "piscine-cpp", - "name": "Piscine CPP" - }, - { - "id": 61, - "slug": "rushes", - "name": "Rushes" - }, - { - "id": 48, - "slug": "piscine-php", - "name": "Piscine PHP" - }, - { - "id": 43, - "slug": "zappy", - "name": "Zappy" - }, - { - "id": 42, - "slug": "lem-ipc", - "name": "Lem-ipc" - }, - { - "id": 41, - "slug": "irc", - "name": "IRC" - }, - { - "id": 40, - "slug": "ft_p", - "name": "ft_p" - }, - { - "id": 39, - "slug": "philosophers", - "name": "Philosophers" - }, - { - "id": 38, - "slug": "ft_script", - "name": "ft_script" - }, - { - "id": 37, - "slug": "nm-otool", - "name": "Nm-otool" - }, - { - "id": 36, - "slug": "malloc", - "name": "Malloc" - }, - { - "id": 35, - "slug": "42sh", - "name": "42sh" - }, - { - "id": 34, - "slug": "ft_sh3", - "name": "ft_sh3" - }, - { - "id": 33, - "slug": "ft_select", - "name": "ft_select" - }, - { - "id": 31, - "slug": "ft_sh2", - "name": "ft_sh2" - }, - { - "id": 29, - "slug": "lem_in", - "name": "Lem_in" - }, - { - "id": 27, - "slug": "push_swap", - "name": "Push_swap" - }, - { - "id": 26, - "slug": "filler", - "name": "Filler" - }, - { - "id": 24, - "slug": "rt", - "name": "RT" - }, - { - "id": 23, - "slug": "rtv1", - "name": "RTv1" - }, - { - "id": 22, - "slug": "corewar", - "name": "Corewar" - }, - { - "id": 15, - "slug": "fract-ol", - "name": "Fract'ol" - }, - { - "id": 11, - "slug": "c-exam-alone-in-the-dark-beginner", - "name": "C Exam Alone In The Dark - Beginner" - }, - { - "id": 8, - "slug": "wolf3d", - "name": "Wolf3d" - }, - { - "id": 7, - "slug": "minishell", - "name": "minishell" - }, - { - "id": 5, - "slug": "ft_printf", - "name": "ft_printf" - }, - { - "id": 4, - "slug": "fdf", - "name": "FdF" - }, - { - "id": 3, - "slug": "ft_ls", - "name": "ft_ls" - }, - { - "id": 2, - "slug": "get_next_line", - "name": "GET_Next_Line" - }, - { - "id": 1, - "slug": "libft", - "name": "Libft" - } -] \ No newline at end of file diff --git a/env/campusIDs.json b/env/campusIDs.json deleted file mode 100644 index c56a54d..0000000 --- a/env/campusIDs.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "Amsterdam": 14, - - "Paris": 1, - "Lyon": 9, - "Brussels": 12, - "Helsinki": 13, - "Khouribga": 16, - "Moscow": 17, - "São-Paulo": 20, - "Benguerir": 21, - "Madrid": 22, - "Kazan": 23, - "Quebec": 25, - "Tokyo": 26, - "Rio de Janeiro": 28, - "Seoul": 29, - "Rome": 30, - "Angouleme": 31, - "Yerevan": 32, - "Bangkok": 33, - "Kuala Lumpur": 34, - "Adelaide": 36, - "Malaga": 37, - "Lisboa": 38, - "Heilbronn": 39, - "Urduliz": 40, - "Nice": 41, - "42Network": 42, - "Abu Dhabi": 43, - "Wolfsburg": 44, - "Alicante": 45, - "Barcelona": 46, - "Lausanne": 47, - "Mulhouse": 48, - "Istanbul": 49, - "Kocaeli": 50, - "Berlin": 51, - "Florence": 52, - "Vienna": 53, - "Tétouan": 55, - "Prague": 56, - "London": 57, - "Porto": 58, - "Le Havre": 62, - "Singapore": 64, - "Antananarivo": 65, - "Warsaw": 67, - "Luanda":68, - "Gyeongsan":69 -} diff --git a/env/projectIDs.json b/env/projectIDs.json deleted file mode 100644 index 78e0c19..0000000 --- a/env/projectIDs.json +++ /dev/null @@ -1,174 +0,0 @@ -{ - "libft": 1314, - "Born2beroot": 1994, - "ft_printf": 1316, - "get_next_line": 1327, - "push_swap": 1471, - "minitalk": 2005, - "pipex": 2004, - "so_long": 2009, - "FdF": 2008, - "fract-ol": 1476, - "minishell": 1331, - "Philosophers": 1334, - "miniRT": 1315, - "cub3d": 1326, - "CPP module 00": 1338, - "CPP module 01": 1339, - "CPP module 02": 1340, - "CPP module 03": 1341, - "CPP module 04": 1342, - "CPP module 05": 1343, - "CPP module 06": 1344, - "CPP module 07": 1345, - "CPP module 08": 1346, - "CPP module 09": 2309, - - "NetPractice": 2007, - "Inception": 1983, - "ft_irc": 1336, - "webserv": 1332, - "ft_containers": 1335, - "ft_transcendence": 1337, - - "Internship I": 1638, - "Internship I - Contract Upload": 1640, - "Internship I - Duration": 1639, - "Internship I - Company Mid Evaluation": 1641, - "Internship I - Company Final Evaluation": 1642, - "Internship I - Peer Video": 1643, - - "startup internship": 1662, - "startup internship - Contract Upload": 1663, - "startup internship - Duration": 1664, - "startup internship - Company Mid Evaluation": 1665, - "startup internship - Company Final Evaluation": 1666, - "startup internship - Peer Video": 1667, - - "Piscine Python Django": 1483, - "camagru": 1396, - "darkly":1405 , - "Piscine Swift iOS":1486 , - "swifty-proteins": 1406, - "ft_hangouts": 1379, - "matcha": 1401, - "swifty-companion": 1395, - "swingy": 1436, - "red-tetris": 1428, - "music-room": 1427, - "hypertube": 1402, - - "rt": 1855, - "scop": 1390, - "zappy": 1463, - "doom_nukem": 1853, - "abstract-vm": 1461, - "humangl": 1394, - "guimp": 1455, - "nibbler": 1386, - "42run": 1387, - "Piscine Unity": 1485, - "gbmu": 1411, - "bomberman": 1389, - "particle-system": 1410, - "in-the-shadows": 1409, - "ft_newton": 1962, - "ft_vox": 1449, - "xv": 1408, - "shaderpixel": 1454, - - "ft_ping": 1397, - "libasm": 1330, - "malloc": 1468, - "nm": 1467, - "strace": 1388, - "ft_traceroute": 1399, - "dr-quine": 1418, - "ft_ssl_md5": 1451, - "snow-crash": 1404, - "ft_nmap": 1400, - "woody-woodpacker": 1419, - "ft_ssl_des": 1452, - "rainfall": 1417, - "ft_malcolm": 1840, - "famine": 1430, - "ft_ssl_rsa": 1450, - "boot2root": 1446, - "matt-daemon": 1420, - "pestilence": 1443, - "override": 1448, - "war": 1444, - "death": 1445, - - "lem_in": 1470, - "computorv1": 1382, - "Piscine OCaml": 1484, - "n-puzzle": 1385, - "ready set boole": 2076, - "computorv2": 1433, - "expert-system": 1384, - "rubik": 1393, - "ft_turing": 1403, - "h42n42": 1429, - "matrix": 2077, - "mod1": 1462, - "gomoku": 1383, - "ft_ality": 1407, - "krpsim": 1392, - "fix-me": 1437, - "ft_linear_regression": 1391, - "dslr": 1453, - "total-perspective-vortex": 1460, - "multilayer-perceptron": 1457, - "ft_kalman": 2098, - - "ft_ls": 1479, - "ft_select": 1469, - "ft_script": 1466, - "42sh": 1854, - "lem-ipc": 1464, - "corewar": 1475, - "taskmaster": 1381, - "ft_linux": 1415, - "little-penguin-1": 1416, - "drivers-and-interrupts": 1422, - "process-and-memory": 1421, - "userspace_digressions": 1456, - "filesystem": 1423, - "kfs-1": 1425, - "kfs-2": 1424, - "kfs-3": 1426, - "kfs-4": 1431, - "kfs-5": 1432, - "kfs-6": 1438, - "kfs-7": 1439, - "kfs-8": 1440, - "kfs-9": 1441, - "kfs-x": 1442, - - "Part_Time I": 1650, - "Open Project": 1635, - "Internship II": 1644, - "Inception-of-Things": 2064, - - "C Piscine Shell 00": 1255, - "C Piscine Shell 01": 1256, - "C Piscine C 00": 1257, - "C Piscine C 01": 1258, - "C Piscine C 02": 1259, - "C Piscine C 03": 1260, - "C Piscine C 04": 1261, - "C Piscine C 05": 1262, - "C Piscine C 06": 1263, - "C Piscine C 07": 1270, - "C Piscine C 08": 1264, - "C Piscine C 09": 1265, - "C Piscine C 10": 1266, - "C Piscine C 11": 1267, - "C Piscine C 12": 1268, - "C Piscine C 13": 1271, - "C Piscine Rush 00": 1308, - "C Piscine Rush 01": 1310, - "C Piscine Rush 02": 1309, - "C Piscine BSQ": 1305 -} From 128873bfcc36628fffa2e1af7ec4bb4c334c69b3 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 19 Aug 2025 14:49:39 +0200 Subject: [PATCH 13/94] Now saves the SyncTimestamp in the SQLite database, instead of a file. The functions are now also part of the DatabaseService class --- .gitignore | 1 - src/services.ts | 25 +++++++++++++++++++++++++ src/sync.ts | 30 ++---------------------------- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index 9ecec26..c152118 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,4 @@ database/*.db /test.ts /sessions/ *.swp -.sync-timestamp /prisma/*.db diff --git a/src/services.ts b/src/services.ts index 68d8bbc..d6e26be 100644 --- a/src/services.ts +++ b/src/services.ts @@ -15,6 +15,20 @@ export class DatabaseService { \*************************************************************************/ + /** + * Get the last synchronization timestamp. + */ + static getLastSyncTimestamp = async function(): Promise { + const sync = await prisma.sync.findUnique({ + where: { id: 1 }, + select: { last_pull: true } + }); + if (!sync) { + throw new Error('No synchronization record found'); + } + return sync.last_pull; + } + /** * Retrieve Users based on the given campus. * @param status The campus to filter on. @@ -106,6 +120,17 @@ export class DatabaseService { * Insert Methods * \*************************************************************************/ + /** + * Save the synchronization timestamp. + * @param timestamp The timestamp to save + */ + static saveSyncTimestamp = async function(timestamp: Date): Promise { + await prisma.sync.upsert({ + where: { id: 1 }, + update: { last_pull: timestamp }, + create: { last_pull: timestamp } + }); + } /** * Inserts a project user into the database. diff --git a/src/sync.ts b/src/sync.ts index c75c897..075c155 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,4 +1,3 @@ -import fs from 'fs'; import Fast42 from "@codam/fast42"; import { env } from "./env"; import { syncData, syncDataCB } from "./wrapper"; @@ -46,10 +45,10 @@ export const syncWithIntra = async function(): Promise { console.info(`Starting Intra synchronization at ${now.toISOString()}...`); try { - const lastSync = await getLastSyncTimestamp(); + const lastSync = await DatabaseService.getLastSyncTimestamp(); await syncProjectUsersCB(fast42Api, lastSync); - await saveSyncTimestamp(now); + await DatabaseService.saveSyncTimestamp(now); console.info(`Intra synchronization completed at ${new Date().toISOString()}.`); } @@ -207,28 +206,3 @@ async function syncProjects(projects: any[]): Promise { throw error; } } - -/** - * Get the last synchronization timestamp. - */ -export const getLastSyncTimestamp = async function(): Promise { - return new Promise((resolve) => { - fs.readFile('.sync-timestamp', 'utf8', (err, data) => { - if (err) { - return resolve(new Date(0)); - } - return resolve(new Date(parseInt(data))); - }); - }); -} - -/** - * Save the synchronization timestamp. - * @param timestamp The timestamp to save - */ -const saveSyncTimestamp = async function(timestamp: Date): Promise { - console.log('Saving timestamp of synchronization to ./.sync-timestamp...'); - // Save to current folder in .sync-timestamp file - fs.writeFileSync('.sync-timestamp', timestamp.getTime().toString()); - console.log('Timestamp saved to ./.sync-timestamp'); -} From 80bcc298ded60721f4e80e6cf1db9896a3dfc4fb Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Fri, 22 Aug 2025 13:31:59 +0200 Subject: [PATCH 14/94] Adjusted msUntilNextPull according to the new database --- src/app.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/app.ts b/src/app.ts index 29d84d5..616361c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,22 +1,28 @@ // eslint-disable-next-line require('dotenv').config({ path: __dirname + '/../env/.env' }) -import { syncCampuses, campusDBs } from './db' import { startWebserver } from './express' import { env } from './env' +import { DatabaseService } from './services'; +import { syncWithIntra } from './sync' import util from 'util' // set depth of object expansion in terminal as printed by console.*() util.inspect.defaultOptions.depth = 10 -function msUntilNextPull(): number { - let nextPull = env.pullTimeout - for (const campus of Object.values(env.campuses)) { - const lastPullAgo = Date.now() - campusDBs[campus.name].lastPull - const msUntilNexPull = Math.max(0, env.pullTimeout - lastPullAgo) - nextPull = Math.min(nextPull, msUntilNexPull) +/** + * Calculates the time in milliseconds until the next pull request should be made. + * @returns How many milliseconds until the next pull request should be made. + */ +async function msUntilNextPull(): Promise { + const lastPullAgo = await DatabaseService.getLastSyncTimestamp().then(date => Date.now() - date.getTime()); + const msUntilNextPull = Math.max(0, env.pullTimeout - lastPullAgo) + if (msUntilNextPull === 0) { + console.warn(`Last pull was more than ${env.pullTimeout / 1000 / 60 / 60} hours ago, pulling immediately.`); + return (0); } - return nextPull + console.info(`Next pull will be in ${(msUntilNextPull / 1000 / 60 / 60).toFixed(2)} hours.`); + return msUntilNextPull } ;(async () => { @@ -24,7 +30,7 @@ function msUntilNextPull(): number { await startWebserver(port) while (true) { - await syncCampuses() - await new Promise(resolve => setTimeout(resolve, msUntilNextPull() + 1000)) + await syncWithIntra() + await new Promise(async resolve => setTimeout(resolve, await msUntilNextPull() + 1000)) } })() From e15ba5ad31e42fafaf0c7dbfb1eb59f0f9804535 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Fri, 22 Aug 2025 13:35:12 +0200 Subject: [PATCH 15/94] Added comment --- src/app.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app.ts b/src/app.ts index 616361c..57d6849 100644 --- a/src/app.ts +++ b/src/app.ts @@ -25,6 +25,7 @@ async function msUntilNextPull(): Promise { return msUntilNextPull } +// Main Program Execution ;(async () => { const port = parseInt(process.env['PORT'] || '8080') await startWebserver(port) From fe865782110439763547ec5efb932a1283d553b9 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Mon, 1 Sep 2025 08:51:23 +0200 Subject: [PATCH 16/94] Added new database using SQLite, replacing JSON. --- package-lock.json | 55 +++++++++++++ package.json | 2 + src/app.ts | 8 +- src/env.ts | 41 +--------- src/express.ts | 191 ++++++++++++++++++++++++++++------------------ src/logger.ts | 17 ----- src/metrics.ts | 90 ---------------------- src/services.ts | 112 ++++++++++++++++++++++----- src/sync.ts | 48 ++++++++---- src/types.ts | 8 ++ views/index.ejs | 6 ++ 11 files changed, 322 insertions(+), 256 deletions(-) delete mode 100644 src/metrics.ts create mode 100644 src/types.ts diff --git a/package-lock.json b/package-lock.json index 94b929c..9f6b942 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "@codam/fast42": "^2.1.6", "@prisma/client": "^6.14.0", "@types/compression": "^1.7.2", + "@types/cookie-parser": "^1.4.9", "@types/ejs": "3.1.0", "@types/express": "^4.17.17", "@types/express-session": "1.17.4", @@ -24,6 +25,7 @@ "@typescript-eslint/parser": "^5.48.2", "42-connector": "github:codam-coding-college/42-connector#3.1.0", "compression": "^1.7.4", + "cookie-parser": "^1.4.7", "crypto": "^1.0.1", "dotenv": "^16.0.3", "ejs": "3.1.8", @@ -284,6 +286,15 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/ejs": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.0.tgz", @@ -1117,6 +1128,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -3447,6 +3480,12 @@ "@types/node": "*" } }, + "@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "requires": {} + }, "@types/ejs": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.0.tgz", @@ -4059,6 +4098,22 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" }, + "cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "requires": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + } + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/package.json b/package.json index 9be1755..2d57486 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@codam/fast42": "^2.1.6", "@prisma/client": "^6.14.0", "@types/compression": "^1.7.2", + "@types/cookie-parser": "^1.4.9", "@types/ejs": "3.1.0", "@types/express": "^4.17.17", "@types/express-session": "1.17.4", @@ -27,6 +28,7 @@ "@typescript-eslint/parser": "^5.48.2", "42-connector": "github:codam-coding-college/42-connector#3.1.0", "compression": "^1.7.4", + "cookie-parser": "^1.4.7", "crypto": "^1.0.1", "dotenv": "^16.0.3", "ejs": "3.1.8", diff --git a/src/app.ts b/src/app.ts index 57d6849..c21eb27 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,7 +15,13 @@ util.inspect.defaultOptions.depth = 10 * @returns How many milliseconds until the next pull request should be made. */ async function msUntilNextPull(): Promise { - const lastPullAgo = await DatabaseService.getLastSyncTimestamp().then(date => Date.now() - date.getTime()); + const lastPullAgo = await DatabaseService.getLastSyncTimestamp().then(date => { + if (!date) { + console.warn('No last sync timestamp found, assuming first pull.'); + return env.pullTimeout; + } + return Date.now() - date.getTime(); + }); const msUntilNextPull = Math.max(0, env.pullTimeout - lastPullAgo) if (msUntilNextPull === 0) { console.warn(`Last pull was more than ${env.pullTimeout / 1000 / 60 / 60} hours ago, pulling immediately.`); diff --git a/src/env.ts b/src/env.ts index 6f3b758..2e31655 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,33 +1,12 @@ -import { log } from './logger' -import path from 'path' -import campusIDs from '../env/campusIDs.json' -import projectIDs from '../env/projectIDs.json' -import { assertEnvInt, assertEnvStr, mapObject } from './util' +import { assertEnvInt, assertEnvStr } from './util' export const DEV_DAYS_LIMIT: number = process.env['DEV_DAYS_LIMIT'] ? parseInt(process.env['DEV_DAYS_LIMIT'] as string) : 365; export const NODE_ENV = process.env['NODE_ENV'] || 'development'; -export type CampusID = (typeof campusIDs)[keyof typeof campusIDs] -export type CampusName = keyof typeof campusIDs - -export interface Campus { - name: CampusName - id: number - databasePath: string // path to the database subfolder for this campus - projectUsersPath: string // users that are subscribed to a project - lastPullPath: string // timestamp for when the server did a last pull -} - export interface Env { logLevel: 0 | 1 | 2 | 3 pullTimeout: number - projectIDs: typeof projectIDs - campusIDs: typeof campusIDs - databaseRoot: string - campuses: Record projectStatuses: typeof projectStatuses - sessionStorePath: string // session key data - userDBpath: string // users associated with sessions scope: string[] authorizationURL: string tokenURL: string @@ -49,15 +28,6 @@ export interface Env { userNewStatusThresholdDays: number } -const databaseRoot = 'database' -const campuses: Record = mapObject(campusIDs, (name, id) => ({ - name, - id, - databasePath: path.join(databaseRoot, name), - projectUsersPath: path.join(databaseRoot, name, 'projectUsers.json'), - lastPullPath: path.join(databaseRoot, name, 'lastpull.txt'), -})) - // known statuses, in the order we want them displayed on the website const projectStatuses = ['creating_group', 'searching_a_group', 'in_progress', 'waiting_for_correction', 'finished', 'parent'] as const export type ProjectStatus = (typeof projectStatuses)[number] @@ -65,15 +35,9 @@ export type ProjectStatus = (typeof projectStatuses)[number] export const env: Readonly = { logLevel: process.env['NODE_ENV'] === 'production' ? 3 : 1, // 0 being no logging pullTimeout: 24 * 60 * 60 * 1000, // how often to pull the project users statuses form the intra api (in Ms) - projectIDs, - campusIDs, - databaseRoot, - campuses, projectStatuses, - sessionStorePath: path.join(databaseRoot, 'sessions'), authorizationURL: 'https://api.intra.42.fr/oauth/authorize', tokenURL: 'https://api.intra.42.fr/oauth/token', - userDBpath: path.join(databaseRoot, 'users.json'), provider: '42', authPath: '/auth/42', scope: ['public'], @@ -92,6 +56,3 @@ export const env: Readonly = { }, userNewStatusThresholdDays: 7, } - -log(1, `Watching ${Object.keys(campusIDs).length} campuses`) -log(1, `Watching ${Object.keys(projectIDs).length} projects`) diff --git a/src/express.ts b/src/express.ts index e56b32d..55a895e 100644 --- a/src/express.ts +++ b/src/express.ts @@ -1,19 +1,18 @@ import path from 'path' import express, { Response } from 'express' import { passport, authenticate } from './authentication' -import { CampusName, env, ProjectStatus } from './env' +import { env, ProjectStatus } from './env' import session from 'express-session' -import { campusDBs, CampusDB } from './db' -import { Project, ProjectSubscriber, UserProfile } from './types' import { log } from './logger' -import { MetricsStorage } from './metrics' import compression from 'compression' -import request from 'request' -import { isLinguisticallySimilar } from './util' +// import request from 'request' +import cookieParser from 'cookie-parser' +import { DatabaseService } from './services' +import { displayProject } from './types' -function errorPage(res: Response, error: string): void { +async function errorPage(res: Response, error: string): Promise { const settings = { - campuses: Object.values(env.campuses).sort((a, b) => (a.name < b.name ? -1 : 1)), + campuses: await DatabaseService.getAllCampuses(), error, } res.render('error.ejs', settings) @@ -21,23 +20,44 @@ function errorPage(res: Response, error: string): void { const cachingProxy = '/proxy' -function filterUsers(users: ProjectSubscriber[], requestedStatus: string | undefined): ProjectSubscriber[] { - const newUsers = users - .filter(user => { - if (user.staff) { - return false - } - if (user.login.match(/^3b3/)) { - // accounts who's login start with 3b3 are deactivated - return false - } - if ((requestedStatus === 'finished' || user.status !== 'finished') && (!requestedStatus || user.status === requestedStatus)) { - return true - } - return false - }) - .map(user => ({ ...user, image_url: `${cachingProxy}?q=${user.image_url}` })) - .sort((a, b) => { +async function getUserCampusFromAPI(accessToken: string): Promise<{ campusId: number, campusName: string }> { + try { + const response = await fetch('https://api.intra.42.fr/v2/me', { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + if (!response.ok) { + throw new Error('Failed to fetch user data from 42 API'); + } + + const userData = await response.json(); + const primaryCampus = userData.campus[0]; // User's primary campus + + return { + campusId: primaryCampus.id, + campusName: primaryCampus.name + }; + } catch (error) { + console.error('Error fetching user campus:', error); + // Fallback to a default campus + return { campusId: 14, campusName: 'Amsterdam' }; + } +} + +async function getProjects(campusId: number, requestedStatus: string | undefined): Promise { + const projectList = await DatabaseService.getAllProjects(); + if (!projectList.length) { + return []; + } + return Promise.all(projectList.map(async project => ({ + name: project.name, + users: (await DatabaseService.getProjectUserInfo(project.id, campusId, requestedStatus)).map(projUser => ({ + login: projUser.login, + image_url: projUser.image_url, + status: projUser.status, + })).sort((a, b) => { if (a.status !== b.status) { const preferredOrder = env.projectStatuses const indexA = preferredOrder.findIndex(x => x === a.status) @@ -46,21 +66,15 @@ function filterUsers(users: ProjectSubscriber[], requestedStatus: string | undef } return a.login < b.login ? -1 : 1 }) - return newUsers + }))); } -function filterProjects(projects: Project[], requestedStatus: string | undefined): Project[] { - return projects.map(project => ({ - name: project.name, - users: filterUsers(project.users, requestedStatus), - })) -} - -const metrics = new MetricsStorage() - export async function startWebserver(port: number) { const app = express() + // Add cookie parser middleware + app.use(cookieParser()) + app.use( session({ secret: env.tokens.userAuth.secret.slice(5), @@ -71,18 +85,55 @@ export async function startWebserver(port: number) { app.use(passport.initialize()) app.use(passport.session()) - app.use(cachingProxy, (req, res) => { + + + // app.use(cachingProxy, (req, res) => { + // const url = req.query['q'] + // if (!url || typeof url !== 'string' || !url.startsWith('http')) { + // res.status(404).send('No URL provided') + // return + // } + + // // inject cache header for images + // res.setHeader('Cache-Control', `public, max-age=${100 * 24 * 60 * 60}`) + // req.pipe(request(url)).pipe(res) + // }) + + app.use(cachingProxy, async (req, res) => { const url = req.query['q'] if (!url || typeof url !== 'string' || !url.startsWith('http')) { res.status(404).send('No URL provided') return } + try { + // inject cache header for images + res.setHeader('Cache-Control', `public, max-age=${100 * 24 * 60 * 60}`) + + const response = await fetch(url) + if (!response.ok) { + res.status(404).send('Resource not found') + return + } + + // Copy headers + response.headers.forEach((value, key) => { + res.setHeader(key, value) + }) - // inject cache header for images - res.setHeader('Cache-Control', `public, max-age=${100 * 24 * 60 * 60}`) - req.pipe(request(url)).pipe(res) + // Stream the response + if (response.body) { + // Convert web ReadableStream to Node.js stream + const { Readable } = require('stream'); + Readable.fromWeb(response.body).pipe(res); + } + } catch (error) { + console.error('Proxy error:', error) + res.status(500).send('Proxy error') + } }) + + app.use((req, res, next) => { try { compression()(req, res, next) @@ -107,26 +158,29 @@ export async function startWebserver(port: number) { }) ) - app.get('/', authenticate, (req, res) => { - const user: UserProfile = req.user as UserProfile - res.redirect(`/${user.campusName}`) - }) + app.get('/', authenticate, async (req, res) => { + const user = req.user as any; + const accessToken = user?.accessToken; - app.get('/:campus', authenticate, (req, res) => { - const user: UserProfile = req.user as UserProfile - const requestedStatus: string | undefined = req.query['status']?.toString() + if (!accessToken) { + return errorPage(res, 'Access token not found for user'); + } + + res.redirect(`/${await getUserCampusFromAPI(accessToken).then(data => data.campusName)}`); + }) - const campus = req.params['campus'] as string - const campusName: CampusName | undefined = Object.keys(campusDBs).find(k => isLinguisticallySimilar(k, campus)) as CampusName | undefined - if (!campusName || !campusDBs[campusName]) { - return errorPage(res, `Campus ${campus} is not supported by Find Peers (yet)`) + app.get('/:campus', authenticate, async (req, res) => { + const user = req.user as any; + const accessToken = user?.accessToken; + if (!accessToken) { + return errorPage(res, 'Access token not found for user'); } + const { campusId, campusName } = await getUserCampusFromAPI(accessToken); - // saving anonymized metrics - metrics.addVisitor(user) + const requestedStatus: string | undefined = req.query['status']?.toString() - const campusDB: CampusDB = campusDBs[campusName] - if (!campusDB.projects.length) { + const campusProjectUserIds = await DatabaseService.getCampusProjectUsersIds(campusId); + if (!campusProjectUserIds.length) { return errorPage(res, 'Empty database (please try again later)') } @@ -134,37 +188,24 @@ export async function startWebserver(port: number) { return errorPage(res, `Unknown status ${req.query['status']}`) } - const { uniqVisitorsTotal: v, uniqVisitorsCampus } = metrics.generateMetrics() - const campuses = uniqVisitorsCampus.reduce((acc, visitors) => { - acc += visitors.month > 0 ? 1 : 0 - return acc - }, 0) + const userTimeZone = req.cookies.timezone || 'Europe/Amsterdam' const settings = { - projects: filterProjects(campusDB.projects, requestedStatus), - lastUpdate: new Date(campusDB.lastPull).toLocaleString('en-NL', { timeZone: user.timeZone }).slice(0, -3), - hoursAgo: ((Date.now() - campusDB.lastPull) / 1000 / 60 / 60).toFixed(2), + projects: await getProjects(campusId, requestedStatus), + lastUpdate: await DatabaseService.getLastSyncTimestamp().then(date => date ? date.toLocaleString('en-NL', { timeZone: userTimeZone }).slice(0, -3) : 'N/A'), + hoursAgo: ((Date.now()) - await DatabaseService.getLastSyncTimestamp().then(date => date ? date.getTime() : 0)).toFixed(2), requestedStatus, projectStatuses: env.projectStatuses, campusName, - campuses: Object.values(env.campuses).sort((a, b) => (a.name < b.name ? -1 : 1)), + campuses: await DatabaseService.getAllCampuses(), updateEveryHours: (env.pullTimeout / 1000 / 60 / 60).toFixed(0), - usage: `${v.day} unique visitors today, ${v.month} this month, from ${campuses} different campuses`, userNewStatusThresholdDays: env.userNewStatusThresholdDays, } res.render('index.ejs', settings) }) - app.get('/status/pull', (_, res) => { - const obj = Object.values(campusDBs).map(campus => ({ - name: campus.name, - lastPull: new Date(campus.lastPull), - hoursAgo: (Date.now() - campus.lastPull) / 1000 / 60 / 60, - })) - res.json(obj) - }) - - app.get('/status/metrics', authenticate, (_, res) => { - res.json(metrics.generateMetrics()) + app.get('/status/pull', async (_, res) => { + const lastSync = await DatabaseService.getLastSyncTimestamp(); + res.json(lastSync); }) app.set('views', path.join(__dirname, '../views')) diff --git a/src/logger.ts b/src/logger.ts index 96dc194..046663d 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,5 +1,4 @@ import { env, Env } from './env' -import { campusDBs } from './db' // eg. 24 export function msToHuman(milliseconds: number): string { @@ -15,27 +14,11 @@ export function msToHuman(milliseconds: number): string { return `${String(h).padStart(2, '0')}h ${String(m).padStart(2, '0')}m ${String(s).padStart(2, '0')}s` } -function longestKeyLength(obj: Record): number { - let longestCampusNameLength = 0 - for (const key in obj) { - if (key.length > longestCampusNameLength) { - longestCampusNameLength = key.length - } - } - return longestCampusNameLength -} - export function nowISO(d: Date | number = new Date()): string { d = new Date(d) return `${d.toISOString().slice(0, -5)}Z` } -export function logCampus(level: Env['logLevel'], campus: string, project: string, message: string) { - if (level <= env.logLevel) { - console.log(`${nowISO()} | ${campus.padEnd(longestKeyLength(campusDBs))} ${project.padEnd(longestKeyLength(env.projectIDs))} | ${message}`) - } -} - export function log(level: Env['logLevel'], message: string) { if (level <= env.logLevel) { console.log(`${nowISO()} | ${message}`) diff --git a/src/metrics.ts b/src/metrics.ts deleted file mode 100644 index ad1999c..0000000 --- a/src/metrics.ts +++ /dev/null @@ -1,90 +0,0 @@ -import fs from 'fs' -import { env } from './env' -import crypto from 'crypto' -import { UserProfile } from './types' -import * as StatsD from './statsd' -import { findLast, unique } from './util' - -interface Visitor { - id: string - campus: string - date: Date -} - -interface Metric { - hour: number - day: number - month: number -} - -interface Metrics { - uniqVisitorsTotal: Metric - uniqVisitorsCampus: ({ name: string } & Metric)[] - nVisitors: number -} - -export class MetricsStorage { - constructor() { - if (!fs.existsSync(this.dbPath)) { - fs.writeFileSync(this.dbPath, '[]') - } - try { - this.visitors = (JSON.parse(fs.readFileSync(this.dbPath, 'utf8')) as Visitor[]).map(x => ({ ...x, date: new Date(x.date) })) // in JSON Date is stored as a string, so now we convert it back to Date - } catch (err) { - console.error('Error while reading visitors database, resetting it...', err) - this.visitors = [] - } - } - - public async addVisitor(user: UserProfile): Promise { - // create a hash instead of storing the user id directly, for privacy - const rawID = user.id.toString() + user.login + env.tokens.metricsSalt - const id = crypto.createHash('sha256').update(rawID).digest('hex') - - // if the user has visited the page in the last n minutes, do not count it as a new visitor - const lastVisit = findLast(this.visitors, x => x.id === id) - if (lastVisit && Date.now() - lastVisit.date.getTime() < 1000 * 60 * 30) { - return - } - - this.visitors.push({ id, campus: user.campusName, date: new Date() }) - StatsD.increment('visits', StatsD.strToTag('origin', user.campusName)) - await fs.promises.writeFile(this.dbPath, JSON.stringify(this.visitors)) - } - - uniqueVisitorsInLast(timeMs: number): Visitor[] { - const now = Date.now() - let visitors = this.visitors.filter(x => now - x.date.getTime() < timeMs) - visitors = unique(visitors, (a, b) => a.id === b.id) - visitors = visitors.map(x => ({ ...x, id: x.id.substring(5, -5) })) // cut a little of the id to keep it private - return visitors - } - - public generateMetrics(): Metrics { - const hour = this.uniqueVisitorsInLast(3600 * 1000) - const day = this.uniqueVisitorsInLast(24 * 3600 * 1000) - const month = this.uniqueVisitorsInLast(30 * 24 * 3600 * 1000) - - const uniqVisitorsCampus = Object.values(env.campuses) - .map(campus => ({ - name: campus.name, - hour: hour.filter(x => x.campus === campus.name).length, - day: day.filter(x => x.campus === campus.name).length, - month: month.filter(x => x.campus === campus.name).length, - })) - .sort((a, b) => b.day - a.day) - - return { - uniqVisitorsTotal: { - hour: hour.length, - day: day.length, - month: month.length, - }, - uniqVisitorsCampus, - nVisitors: this.visitors.length, - } - } - - private readonly dbPath: string = `${env.databaseRoot}/visitors.json` - private visitors: Visitor[] = [] -} diff --git a/src/services.ts b/src/services.ts index d6e26be..614bfe5 100644 --- a/src/services.ts +++ b/src/services.ts @@ -1,4 +1,5 @@ import { PrismaClient, User, Project, Campus, ProjectUser } from '@prisma/client' +import { log } from 'console'; const prisma = new PrismaClient(); @@ -16,17 +17,53 @@ export class DatabaseService { /** - * Get the last synchronization timestamp. + * @returns The last synchronization timestamp. */ - static getLastSyncTimestamp = async function(): Promise { + static getLastSyncTimestamp = async function(): Promise { const sync = await prisma.sync.findUnique({ where: { id: 1 }, select: { last_pull: true } }); - if (!sync) { - throw new Error('No synchronization record found'); - } - return sync.last_pull; + return sync?.last_pull || null; + } + + /** + * Get project users by project, status, and campus. + * @param project The project to filter by. + * @param requestedStatus The status to filter by. + * @param campus The campus to filter by. + * @returns Project; name. Users; login, image_url. Project Status. + */ + static async getProjectUserInfo( + project_id: any, campus_id: any, requestedStatus: string | undefined): Promise { + const whereClause: any = { + project_id: project_id, + user: { primary_campus_id: campus_id } + }; + if (typeof requestedStatus === 'string') { + whereClause.status = requestedStatus; + } + + return prisma.projectUser.findMany({ + where: whereClause, + select: { + project: { select: { name: true } }, + user: { select: { login: true, image_url: true } }, + status: true + } + }); + } + + /** + * Get the name of a campus by its ID. + * @param campus_id The campus ID to look for. + * @returns The name of the campus. + */ + static async getCampusNameById(campus_id: any): Promise<{ name: string } | null> { + return prisma.campus.findUnique({ + where: { id: campus_id }, + select: { name: true } + }); } /** @@ -51,14 +88,45 @@ export class DatabaseService { }); } + /** + * Retrieve Project Users IDs based on the given campus. + * @param campus_id The campus ID to filter on. + * @returns The list of project user IDs. + */ + static async getCampusProjectUsersIds(campus_id: number): Promise<{ user_id: number; }[]> { + return prisma.projectUser.findMany({ + where: { user: { primary_campus_id: campus_id } }, + select: { user_id: true } + }); + } + + /** + * @returns The list of all campuses in ascending (name) order. + */ + static async getAllCampuses(): Promise { + return prisma.campus.findMany({ + orderBy: { name: 'asc' } + }); + } + + /** + * @returns The list of all projects in ascending (id) order. + */ + static async getAllProjects(): Promise { + return prisma.project.findMany({ + orderBy: { id: 'asc' } + }); + } + /** * Retrieve user IDs that are missing from the user table. * @param projectUsers The list of project users to check against the database. * @returns The list of missing user IDs. */ static async getMissingUserIds(projectUsers: any[]): Promise { - const userIds = [...new Set(projectUsers.map(pu => pu.user_id))]; + const usersArray = Array.isArray(projectUsers) ? projectUsers : [projectUsers]; + const userIds = [...new Set(usersArray.map(pu => pu.user.id).filter((id) => id !== null && id !== undefined))]; const existingUsers = await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true } @@ -74,7 +142,9 @@ export class DatabaseService { * @returns The list of missing project IDs. */ static async getMissingProjects(projectUsers: any[]): Promise { - const projectIds = [...new Set(projectUsers.map(pu => pu.project_id))]; + const projectsArray = Array.isArray(projectUsers) ? projectUsers : [projectUsers]; + + const projectIds = [...new Set(projectsArray.map(pu => pu.project.id).filter((id) => id !== null && id !== undefined))]; const existingProjects = await prisma.project.findMany({ where: { id: { in: projectIds } }, select: { @@ -83,19 +153,22 @@ export class DatabaseService { slug: true } }); - const existingProjectIds = new Set(existingProjects.map(p => p.id)); - const missingProjectIds = projectIds.filter(id => !existingProjectIds.has(id)); - const projectDataMap = new Map(); + const existingProjectIds: Set = new Set(existingProjects.map((p: { id: number }) => p.id)); + const missingProjects = projectIds.filter(id => !existingProjectIds.has(id)); + const projectDataMap: Map = new Map(); projectUsers.forEach(pu => { - if (!projectDataMap.has(pu.project_id)) { - projectDataMap.set(pu.project_id, { - id: pu.project_id, - name: pu.name, - slug: pu.slug - }); + if (!projectDataMap.has(pu.project.id)) { + projectDataMap.set(pu.project.id, pu.project); } }); - return missingProjectIds.map(id => projectDataMap.get(id)); + return missingProjects.map(id => { + const project = projectDataMap.get(id); + return { + id, + name: project.name, + slug: project.slug + }; + }); } /** @@ -104,8 +177,9 @@ export class DatabaseService { * @returns The list of missing campus IDs. */ static async getMissingCampusIds(users: any[]): Promise { - const campusIds = [...new Set(users.map(u => u.primary_campus_id))]; + const usersArray = Array.isArray(users) ? users : [users]; + const campusIds = [...new Set(usersArray.map(u => u.primary_campus_id).filter((id) => id !== null && id !== undefined))]; const existingCampus = await prisma.campus.findMany({ where: { id: { in: campusIds } }, select: { id: true } diff --git a/src/sync.ts b/src/sync.ts index 075c155..81d211b 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -3,6 +3,7 @@ import { env } from "./env"; import { syncData, syncDataCB } from "./wrapper"; import { transformApiCampusToDb, transformApiProjectUserToDb, transformApiUserToDb, transformApiProjectToDb } from './transform'; import { DatabaseService } from './services'; +import { log } from "./logger"; @@ -45,7 +46,8 @@ export const syncWithIntra = async function(): Promise { console.info(`Starting Intra synchronization at ${now.toISOString()}...`); try { - const lastSync = await DatabaseService.getLastSyncTimestamp(); + let lastSyncRaw = await DatabaseService.getLastSyncTimestamp(); + let lastSync: Date | undefined = lastSyncRaw === null ? undefined : lastSyncRaw; await syncProjectUsersCB(fast42Api, lastSync); await DatabaseService.saveSyncTimestamp(now); @@ -67,7 +69,7 @@ export const syncWithIntra = async function(): Promise { * @param lastPullDate The date of the last synchronization * @returns A promise that resolves when the synchronization is complete */ -async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date): Promise { +async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined): Promise { return new Promise((resolve, reject) => { const callback = async (projectUsers: any[]) => { try { @@ -79,6 +81,9 @@ async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date): Promis // If any project doesn't exist in the 'project' table, create an entry in 'project' table. const missingProjects = await DatabaseService.getMissingProjects(projectUsers); + missingProjects.forEach(project => { + log(2, `Missing Project - ID: ${project.id}, Name: ${project.name}`); + }); if (missingProjects.length > 0) { console.log(`Found ${missingProjects.length} missing projects, syncing...`); await syncProjects(missingProjects); @@ -91,16 +96,21 @@ async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date): Promis await syncUsersCB(fast42Api, lastPullDate, missingUserIds); } - const dbProjectUsers = projectUsers.map(transformApiProjectUserToDb); - await DatabaseService.insertManyProjectUsers(dbProjectUsers); - } catch (error) { - console.error('Failed to process project users batch:', error); - throw error; + if (projectUsers.length > 1) { + const dbProjectUsers = projectUsers.map(transformApiProjectUserToDb); + await DatabaseService.insertManyProjectUsers(dbProjectUsers); + } else if (projectUsers.length === 1) { + const dbProjectUser = transformApiProjectUserToDb(projectUsers[0]); + await DatabaseService.insertProjectUser(dbProjectUser); + } + } catch (error) { + console.error('Failed to process project users batch:', error); + throw error; } }; syncDataCB(fast42Api, new Date(), lastPullDate, '/projects_users', - { 'page[size]': '100' }, callback) + { 'page[size]': '100', 'filter[campus]': '14' }, callback) .then(() => { console.log('Finished syncing project users with callback method.'); resolve(); @@ -118,7 +128,7 @@ async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date): Promis * @param lastPullDate The date of the last synchronization * @returns A promise that resolves when the synchronization is complete */ -async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date, userIds: number[]): Promise { +async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined, userIds: number[]): Promise { const promises = userIds.map(userId => { return new Promise((resolve, reject) => { const callback = async (users: any[]) => { @@ -136,8 +146,13 @@ async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date, userIds: numbe await syncCampus(fast42Api, lastPullDate, missingCampusIds); } - const dbUsers = users.map(transformApiUserToDb); - await DatabaseService.insertManyUsers(dbUsers); + if (users.length > 1) { + const dbUsers = users.map(transformApiUserToDb); + await DatabaseService.insertManyUsers(dbUsers); + } else if (users.length === 1) { + const dbUser = transformApiUserToDb(users[0]); + await DatabaseService.insertUser(dbUser); + } } catch (error) { console.error('Failed to process project users batch:', error); throw error; @@ -168,7 +183,7 @@ async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date, userIds: numbe * @param campusIds The IDs of the campuses to sync * @returns A promise that resolves when the synchronization is complete */ -async function syncCampus(fast42Api: Fast42, lastPullDate: Date, campusIds: number[]): Promise { +async function syncCampus(fast42Api: Fast42, lastPullDate: Date | undefined, campusIds: number[]): Promise { // Fetch all campuses in one API call using filter[id] try { const campuses = await syncData(fast42Api, new Date(), lastPullDate, '/campus', @@ -181,8 +196,13 @@ async function syncCampus(fast42Api: Fast42, lastPullDate: Date, campusIds: numb } console.log(`Processing ${campuses.length} campuses...`); - const dbCampuses = campuses.map(transformApiCampusToDb); - await DatabaseService.insertManyCampuses(dbCampuses); + if (campuses.length > 1) { + const dbCampuses = campuses.map(transformApiCampusToDb); + await DatabaseService.insertManyCampuses(dbCampuses); + } else if (campuses.length === 1) { + const dbCampus = transformApiCampusToDb(campuses[0]); + await DatabaseService.insertCampus(dbCampus); + } console.log('Finished syncing campuses.'); } catch (error) { console.error('Failed to sync campuses:', error); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..32b30fd --- /dev/null +++ b/src/types.ts @@ -0,0 +1,8 @@ +export interface displayProject { + name: string, + users: { + login: string, + image_url: string, + status: string, + }[] +} diff --git a/views/index.ejs b/views/index.ejs index 381acb6..7f0cb31 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -151,6 +151,12 @@ From 83c7a8bd43a43afcd1ec89ac715ba5efb1a692a3 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Mon, 1 Sep 2025 15:32:51 +0200 Subject: [PATCH 17/94] Added front-end to match the new database. Also fixed the database insert/query functions --- src/express.ts | 28 +++-- src/services.ts | 88 ++++++++++---- src/sync.ts | 309 +++++++++++++++++++++++++++++++---------------- src/transform.ts | 15 +-- src/wrapper.ts | 2 +- views/index.ejs | 10 +- 6 files changed, 297 insertions(+), 155 deletions(-) diff --git a/src/express.ts b/src/express.ts index 55a895e..12d0432 100644 --- a/src/express.ts +++ b/src/express.ts @@ -51,11 +51,11 @@ async function getProjects(campusId: number, requestedStatus: string | undefined if (!projectList.length) { return []; } - return Promise.all(projectList.map(async project => ({ + const projectsWithUsers: displayProject[] = await Promise.all(projectList.map(async project => ({ name: project.name, users: (await DatabaseService.getProjectUserInfo(project.id, campusId, requestedStatus)).map(projUser => ({ - login: projUser.login, - image_url: projUser.image_url, + login: projUser.user.login, + image_url: projUser.user.image_url, status: projUser.status, })).sort((a, b) => { if (a.status !== b.status) { @@ -67,6 +67,10 @@ async function getProjects(campusId: number, requestedStatus: string | undefined return a.login < b.login ? -1 : 1 }) }))); + return projectsWithUsers.map(project => ({ + ...project, + users: project.users.filter(user => !user.login.match(/^3b3/) && !user.login.match(/^3c3/)) + })); } export async function startWebserver(port: number) { @@ -175,15 +179,18 @@ export async function startWebserver(port: number) { if (!accessToken) { return errorPage(res, 'Access token not found for user'); } - const { campusId, campusName } = await getUserCampusFromAPI(accessToken); + let { campusId, campusName } = await getUserCampusFromAPI(accessToken); - const requestedStatus: string | undefined = req.query['status']?.toString() - - const campusProjectUserIds = await DatabaseService.getCampusProjectUsersIds(campusId); - if (!campusProjectUserIds.length) { - return errorPage(res, 'Empty database (please try again later)') + if (req.params['campus'] !== undefined) { + campusName = req.params['campus']; + campusId = await DatabaseService.getCampusIdByName(campusName); + if (campusId === -1) { + return errorPage(res, `Unknown campus ${campusName}`); + } } + const requestedStatus: string | undefined = req.query['status']?.toString() + if (requestedStatus && !env.projectStatuses.includes(requestedStatus as ProjectStatus)) { return errorPage(res, `Unknown status ${req.query['status']}`) } @@ -191,8 +198,9 @@ export async function startWebserver(port: number) { const userTimeZone = req.cookies.timezone || 'Europe/Amsterdam' const settings = { projects: await getProjects(campusId, requestedStatus), + users: await DatabaseService.getUsersByCampus(campusId), lastUpdate: await DatabaseService.getLastSyncTimestamp().then(date => date ? date.toLocaleString('en-NL', { timeZone: userTimeZone }).slice(0, -3) : 'N/A'), - hoursAgo: ((Date.now()) - await DatabaseService.getLastSyncTimestamp().then(date => date ? date.getTime() : 0)).toFixed(2), + hoursAgo: (((Date.now()) - await DatabaseService.getLastSyncTimestamp().then(date => date ? date.getTime() : 0)) / (1000 * 60 * 60)).toFixed(2), // hours ago requestedStatus, projectStatuses: env.projectStatuses, campusName, diff --git a/src/services.ts b/src/services.ts index 614bfe5..646270e 100644 --- a/src/services.ts +++ b/src/services.ts @@ -24,7 +24,11 @@ export class DatabaseService { where: { id: 1 }, select: { last_pull: true } }); - return sync?.last_pull || null; + if (sync?.last_pull === null || sync?.last_pull === undefined) { + log(2, `No last sync timestamp found, returning null.`); + return null; + } + return new Date(sync.last_pull); } /** @@ -36,22 +40,25 @@ export class DatabaseService { */ static async getProjectUserInfo( project_id: any, campus_id: any, requestedStatus: string | undefined): Promise { - const whereClause: any = { - project_id: project_id, - user: { primary_campus_id: campus_id } - }; - if (typeof requestedStatus === 'string') { - whereClause.status = requestedStatus; - } + const whereClause: any = { + project_id: project_id, + user: { primary_campus_id: campus_id } + }; + if (typeof requestedStatus === 'string' && requestedStatus.length > 0) { + whereClause.status = requestedStatus; + } - return prisma.projectUser.findMany({ - where: whereClause, - select: { - project: { select: { name: true } }, - user: { select: { login: true, image_url: true } }, - status: true - } - }); + const projusers = await prisma.projectUser.findMany({ + where: whereClause, + select: { + user: { select: { login: true, image_url: true } }, + status: true + } + }); + if (requestedStatus == 'finished') { + return projusers; + } + return projusers.filter(pu => pu.status !== 'finished'); } /** @@ -66,6 +73,16 @@ export class DatabaseService { }); } + static async getCampusIdByName(campus_name: string | undefined): Promise { + if (!campus_name) return -1; + + const campus = await prisma.campus.findFirst({ + where: { name: campus_name }, + select: { id: true } + }); + return campus?.id ?? -1; + } + /** * Retrieve Users based on the given campus. * @param status The campus to filter on. @@ -109,6 +126,15 @@ export class DatabaseService { }); } + /** + * @returns The list of all users in ascending (id) order. + */ + static async getAllUsers(): Promise { + return prisma.user.findMany({ + orderBy: { id: 'asc' } + }); + } + /** * @returns The list of all projects in ascending (id) order. */ @@ -189,6 +215,21 @@ export class DatabaseService { return campusIds.filter(id => !existingCampusIds.has(id)); } + static async getMissingCampusId(user: any): Promise { + const campusId = user.campus_users.find((cu: any) => cu.is_primary)?.campus_id; + if (campusId === null || campusId === undefined) { + return null; + } + const existingCampus = await prisma.campus.findUnique({ + where: { id: campusId }, + select: { id: true } + }); + if (!existingCampus) { + return campusId; + } + return null; + } + /*************************************************************************\ * Insert Methods * @@ -201,8 +242,8 @@ export class DatabaseService { static saveSyncTimestamp = async function(timestamp: Date): Promise { await prisma.sync.upsert({ where: { id: 1 }, - update: { last_pull: timestamp }, - create: { last_pull: timestamp } + update: { last_pull: timestamp.toISOString() }, + create: { id: 1, last_pull: timestamp.toISOString() } }); } @@ -277,13 +318,13 @@ export class DatabaseService { */ static async insertManyUsers(users: User[]): Promise { try { - const insert = users.map(user => - prisma.user.upsert({ + const insert = users.map(user => { + return prisma.user.upsert({ where: { id: user.id }, update: user, create: user - }) - ); + }); + }); await prisma.$transaction(insert); } catch (error) { throw new Error(`Failed to insert users: ${getErrorMessage(error)}`); @@ -297,13 +338,14 @@ export class DatabaseService { */ static async insertCampus(campus: Campus): Promise { try { + log(2, `-------------Inserted campus`); return prisma.campus.upsert({ where: { id: campus.id }, update: campus, create: campus }); } catch (error) { - throw new Error(`Failed to insert user ${campus.id}: ${getErrorMessage(error)}`); + throw new Error(`-------Failed to insert campus ${campus.id}: ${getErrorMessage(error)}`); } } diff --git a/src/sync.ts b/src/sync.ts index 81d211b..4a0fc02 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -4,6 +4,8 @@ import { syncData, syncDataCB } from "./wrapper"; import { transformApiCampusToDb, transformApiProjectUserToDb, transformApiUserToDb, transformApiProjectToDb } from './transform'; import { DatabaseService } from './services'; import { log } from "./logger"; +// import path from "path"; +// import fs from "fs"; @@ -44,7 +46,7 @@ export const syncWithIntra = async function(): Promise { } const now = new Date(); - console.info(`Starting Intra synchronization at ${now.toISOString()}...`); + // console.info(`Starting Intra synchronization at ${now.toISOString()}...`); try { let lastSyncRaw = await DatabaseService.getLastSyncTimestamp(); let lastSync: Date | undefined = lastSyncRaw === null ? undefined : lastSyncRaw; @@ -71,48 +73,69 @@ export const syncWithIntra = async function(): Promise { */ async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined): Promise { return new Promise((resolve, reject) => { + const batches: any[][] = []; // Store all batches first + const callback = async (projectUsers: any[]) => { try { if (projectUsers.length === 0) { - console.log('No project users found to sync.'); + log(2, 'No project users found to sync.'); return; } - console.log(`Processing batch of ${projectUsers.length} project users...`); - - // If any project doesn't exist in the 'project' table, create an entry in 'project' table. - const missingProjects = await DatabaseService.getMissingProjects(projectUsers); - missingProjects.forEach(project => { - log(2, `Missing Project - ID: ${project.id}, Name: ${project.name}`); - }); - if (missingProjects.length > 0) { - console.log(`Found ${missingProjects.length} missing projects, syncing...`); - await syncProjects(missingProjects); - } - - // If any projectUser doesn't exist in the 'user' table, create an entry in 'user' table. - const missingUserIds = await DatabaseService.getMissingUserIds(projectUsers); - if (missingUserIds.length > 0) { - console.log(`Found ${missingUserIds.length} missing users, syncing...`); - await syncUsersCB(fast42Api, lastPullDate, missingUserIds); - } - - if (projectUsers.length > 1) { - const dbProjectUsers = projectUsers.map(transformApiProjectUserToDb); - await DatabaseService.insertManyProjectUsers(dbProjectUsers); - } else if (projectUsers.length === 1) { - const dbProjectUser = transformApiProjectUserToDb(projectUsers[0]); - await DatabaseService.insertProjectUser(dbProjectUser); - } - } catch (error) { - console.error('Failed to process project users batch:', error); - throw error; + // Just collect batches, don't process yet + batches.push(projectUsers); + log(2, `Collected batch of ${projectUsers.length} project users...`); + } catch (error) { + console.error('Failed to collect project users batch:', error); + throw error; } }; syncDataCB(fast42Api, new Date(), lastPullDate, '/projects_users', - { 'page[size]': '100', 'filter[campus]': '14' }, callback) - .then(() => { - console.log('Finished syncing project users with callback method.'); + { 'page[size]': '100', 'filter[campus]': '14' }, callback) + .then(async () => { + log(2, `Collected ${batches.length} batches. Processing sequentially...`); + + // NOW process all batches sequentially using for...of + let batchIndex = 0; + for (const batch of batches) { + batchIndex++; + log(2, `Processing batch ${batchIndex}/${batches.length} (${batch.length} project users)...`); + + // Process this batch sequentially + const missingProjects = await DatabaseService.getMissingProjects(batch); + if (missingProjects.length > 0) { + log(2, `Found ${missingProjects.length} missing projects, syncing...`); + await syncProjects(missingProjects); + } + + const missingUserIds = await DatabaseService.getMissingUserIds(batch); + if (missingUserIds.length > 0) { + log(2, `Found ${missingUserIds.length} missing users, syncing...`); + await syncUsersCB(fast42Api, lastPullDate, missingUserIds); + } + + + // Double-check missing projects and users + const stillMissingProjects = await DatabaseService.getMissingProjects(batch); + const stillMissingUsers = await DatabaseService.getMissingUserIds(batch); + + if (stillMissingProjects.length > 0) { + console.error(`ERROR: Still missing ${stillMissingProjects.length} projects after sync:`, stillMissingProjects); + await syncProjects(stillMissingProjects); + } + + if (stillMissingUsers.length > 0) { + console.error(`ERROR: Still missing ${stillMissingUsers.length} users after sync:`, stillMissingUsers); + await syncUsersCB(fast42Api, lastPullDate, stillMissingUsers); + } + + + log(2, `-------Inserting ${batch.length} project users...`); + const dbProjectUsers = batch.map(transformApiProjectUserToDb); + await DatabaseService.insertManyProjectUsers(dbProjectUsers); + } + + log(2, 'Finished syncing project users with sequential batch method.'); resolve(); }) .catch((error) => { @@ -121,6 +144,59 @@ async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date | undefi }); }); } +// async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined): Promise { +// return new Promise((resolve, reject) => { +// const callback = async (projectUsers: any[]) => { +// try { +// if (projectUsers.length === 0) { +// console.log('No project users found to sync.'); +// return; +// } +// console.log(`Processing batch of ${projectUsers.length} project users...`); + +// // If any project doesn't exist in the 'project' table, create an entry in 'project' table. +// const missingProjects = await DatabaseService.getMissingProjects(projectUsers); +// if (missingProjects.length > 0) { +// console.log(`Found ${missingProjects.length} missing projects, syncing...`); +// await syncProjects(missingProjects); +// } + +// // If any projectUser doesn't exist in the 'user' table, create an entry in 'user' table. +// const missingUserIds = await DatabaseService.getMissingUserIds(projectUsers); +// if (missingUserIds.length > 0) { +// console.log(`Found ${missingUserIds.length} missing users, syncing...`); +// await syncUsersCB(fast42Api, lastPullDate, missingUserIds); +// } + +// log(2, `-------Inserting ${projectUsers.length} project users...`); +// if (projectUsers.length > 1) { +// const dbProjectUsers = projectUsers.map(transformApiProjectUserToDb); +// await DatabaseService.insertManyProjectUsers(dbProjectUsers); +// } else if (projectUsers.length === 1) { +// const dbProjectUser = transformApiProjectUserToDb(projectUsers[0]); +// await DatabaseService.insertProjectUser(dbProjectUser); +// } else if (!Array.isArray(projectUsers)) { +// const dbProjectUser = transformApiProjectUserToDb(projectUsers); +// await DatabaseService.insertProjectUser(dbProjectUser); +// } +// } catch (error) { +// console.error('Failed to process project users batch:', error); +// throw error; +// } +// }; + +// syncDataCB(fast42Api, new Date(), lastPullDate, '/projects_users', +// { 'page[size]': '100', 'filter[campus]': '14' }, callback) +// .then(() => { +// console.log('Finished syncing project users with callback method.'); +// resolve(); +// }) +// .catch((error) => { +// console.error('Failed to sync project users with callback:', error); +// reject(error); +// }); +// }); +// } /** * Sync users with the Fast42 API using a callback. @@ -129,52 +205,88 @@ async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date | undefi * @returns A promise that resolves when the synchronization is complete */ async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined, userIds: number[]): Promise { - const promises = userIds.map(userId => { - return new Promise((resolve, reject) => { - const callback = async (users: any[]) => { - try { - if (users.length == 0) { - console.log('No users found to sync.'); - return; - } - console.log(`Processing batch of ${users.length} users...`); - - // If any projectUser doesn't exist in the 'user' table, create an entry in 'user' table. - const missingCampusIds = await DatabaseService.getMissingCampusIds(users); - if (missingCampusIds.length > 0) { - console.log(`Found ${missingCampusIds.length} missing campuses, syncing...`); - await syncCampus(fast42Api, lastPullDate, missingCampusIds); - } - - if (users.length > 1) { - const dbUsers = users.map(transformApiUserToDb); - await DatabaseService.insertManyUsers(dbUsers); - } else if (users.length === 1) { - const dbUser = transformApiUserToDb(users[0]); - await DatabaseService.insertUser(dbUser); - } - } catch (error) { - console.error('Failed to process project users batch:', error); - throw error; - } - }; - - // Fetch user for each userId - syncDataCB(fast42Api, new Date(), lastPullDate, `/users/${userId}`, - { 'page[size]': '100' }, callback) - .then(() => { - console.log('Finished syncing users with callback method.'); - resolve(undefined); - }) - .catch((error) => { - console.error('Failed to sync users with callback:', error); - reject(error); - }); - }); - }); - await Promise.all(promises); - console.log('Syncing Users completed.'); + // Process users ONE AT A TIME instead of all at once + for (const userId of userIds) { + try { + log(2, `Processing missing user ${userId}...`); + + // WAIT for this API call to complete before moving to next user + const userApi = await syncData(fast42Api, new Date(), lastPullDate, `/users/${userId}`, {}); + const user = userApi[0]; + + if (!user) { + log(2, `No user data found for ID: ${userId}`); + continue; // Skip to next user + } + + const dbUser = transformApiUserToDb(user); + + // Check for missing campus for THIS user only + const missingCampusId = await DatabaseService.getMissingCampusId(user); + if (missingCampusId !== null) { + log(2, `Found missing campus ID ${missingCampusId}, syncing...`); + // GUARANTEED: Campus insert completes BEFORE user insert + try { + await syncCampus(fast42Api, lastPullDate, missingCampusId); + } catch (error) { + console.error(`Assigning to non-existent campus; failed to fetch ${missingCampusId}:`, error); + DatabaseService.insertCampus({ id: 1, name: `Ghost Campus` }); + dbUser.primary_campus_id = 1; // Assign to Ghost Campus + } + } + // SAFE: Campus definitely exists in database by now + await DatabaseService.insertUser(dbUser); + + } catch (error) { + console.error(`Failed to process user ${userId}:`, error); + // Continue with next user instead of failing completely + } + } + log(2, 'Syncing Users completed.'); } +// async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined, userIds: number[]): Promise { +// const promises = userIds.map(userId => { +// return new Promise((resolve, reject) => { +// const callback = async (user: any) => { +// try { +// if (!user) { +// console.log('No users found to sync.'); +// return; +// } +// console.log(`-----------Processing missing user ${user.id}...`); + +// // If any projectUser doesn't exist in the 'user' table, create an entry in 'user' table. +// const missingCampusId = await DatabaseService.getMissingCampusId(user); +// log(2, `-----------Missing Campus ID for user ${user.id}: ${missingCampusId}`); +// if (missingCampusId !== null) { +// console.log(`------------------Found missing campus ID ${missingCampusId}, syncing...`); +// await syncCampus(fast42Api, lastPullDate, missingCampusId); +// } + +// const dbUser = transformApiUserToDb(user); +// await DatabaseService.insertUser(dbUser); +// } catch (error) { +// console.error('Failed to process project users batch:', error); +// throw error; +// } +// }; + +// // Fetch user for each userId +// syncDataCB(fast42Api, new Date(), lastPullDate, `/users/${userId}`, +// { 'page[size]': '100' }, callback) +// .then(() => { +// console.log('Finished syncing users with callback method.'); +// resolve(undefined); +// }) +// .catch((error) => { +// console.error('Failed to sync users with callback:', error); +// reject(error); +// }); +// }); +// }); +// await Promise.all(promises); +// console.log('Syncing Users completed.'); +// } /** * Sync campuses with the Fast42 API. @@ -183,29 +295,22 @@ async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined, us * @param campusIds The IDs of the campuses to sync * @returns A promise that resolves when the synchronization is complete */ -async function syncCampus(fast42Api: Fast42, lastPullDate: Date | undefined, campusIds: number[]): Promise { - // Fetch all campuses in one API call using filter[id] +async function syncCampus(fast42Api: Fast42, lastPullDate: Date | undefined, campusId: number): Promise { + let campusApi; + try { + campusApi = await syncData(fast42Api, new Date(), lastPullDate, `/campus/${campusId}`, {}); + } catch (error) { + console.error(`Campus ${campusId} doesn't exist`, error); + throw error; + } try { - const campuses = await syncData(fast42Api, new Date(), lastPullDate, '/campus', - { 'page[size]': '100', 'filter[id]': campusIds.join(',') } - ); - - if (!Array.isArray(campuses) || campuses.length === 0) { - console.log(`No campuses found with ids: ${campusIds.join(", ")}.`); - return; - } - - console.log(`Processing ${campuses.length} campuses...`); - if (campuses.length > 1) { - const dbCampuses = campuses.map(transformApiCampusToDb); - await DatabaseService.insertManyCampuses(dbCampuses); - } else if (campuses.length === 1) { - const dbCampus = transformApiCampusToDb(campuses[0]); - await DatabaseService.insertCampus(dbCampus); - } - console.log('Finished syncing campuses.'); + const campus = campusApi[0]; + log(2, `Syncing campus ${campus}...`); + const dbCampus = transformApiCampusToDb(campus); + await DatabaseService.insertCampus(dbCampus); + log(2, `Finished syncing campus ${dbCampus.id} - ${dbCampus.name}`); } catch (error) { - console.error('Failed to sync campuses:', error); + console.error(`Failed to sync campus ${campusId}:`, error); throw error; } } @@ -217,10 +322,10 @@ async function syncCampus(fast42Api: Fast42, lastPullDate: Date | undefined, cam */ async function syncProjects(projects: any[]): Promise { try { - console.log(`Processing ${projects.length} projects...`); + log(2, `Processing ${projects.length} projects...`); const dbProjects = projects.map(transformApiProjectToDb); await DatabaseService.insertManyProjects(dbProjects); - console.log('Finished syncing projects.'); + log(2, 'Finished syncing projects.'); } catch (error) { console.error('Failed to sync projects:', error); throw error; diff --git a/src/transform.ts b/src/transform.ts index d96ffe0..cd6bd99 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -22,19 +22,14 @@ export function transformApiProjectUserToDb(apiProjectUser: any): ProjectUser { * @returns User object for the database */ export function transformApiUserToDb(apiUser: any): User { - let primaryCampus; - if (apiUser.campus_users && apiUser.campus_users.length > 1) { - // get campus where campus_users[i].primary is true - primaryCampus = apiUser.campus_users.find((cu: any) => cu.primary); + const primaryCampus = apiUser.campus_users.find((cu: any) => cu.is_primary); + if (apiUser.staff) { + apiUser.login = "3c3" + apiUser.login; } - else if (apiUser.campus_users && apiUser.campus_users.length === 1) { - primaryCampus = apiUser.campus_users[0]; - } - return { id: apiUser.id, login: apiUser.login, - primary_campus_id: primaryCampus ? primaryCampus.campus_id : null, + primary_campus_id: primaryCampus ? primaryCampus.campus_id : 1, image_url: apiUser.image?.versions?.medium || null, anonymize_date: apiUser.anonymize_date || null }; @@ -48,7 +43,7 @@ export function transformApiUserToDb(apiUser: any): User { export function transformApiCampusToDb(apiCampus: any): Campus { return { id: apiCampus.id, - name: apiCampus.name || '' + name: apiCampus.name }; } diff --git a/src/wrapper.ts b/src/wrapper.ts index b062283..14d9051 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -189,7 +189,7 @@ export const syncDataCB = async function(api: Fast42, syncDate: Date, lastSyncDa lastSyncDate = new Date(lastSyncDate.getTime() - 72 * 60 * 60 * 1000); params['range[begin_at]'] = `${lastSyncDate.toISOString()},${syncDate.toISOString()}`; } - console.log(`Fetching data from Intra API updated on path ${path} since ${lastSyncDate.toISOString()}...`); + // console.log(`Fetching data from Intra API updated on path ${path} since ${lastSyncDate.toISOString()}...`); } else { console.log(`Fetching all data from Intra API on path ${path}...`); diff --git a/views/index.ejs b/views/index.ejs index 7f0cb31..d98c506 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -158,19 +158,15 @@ } setTimezoneCookie(); - -
Find Peers
Built by Joppe Koers/@jkoers - Source
This page is updated every <%= updateEveryHours %> hours, last update was on <%= lastUpdate %>: <%= hoursAgo %> hours ago
- indicates that the user's project status was changed in the last <%= userNewStatusThresholdDays %> days
- <%= usage %>
@@ -216,9 +211,6 @@ <% } else { %> <% project.users.forEach(user=>{ %> - <% if (user.new) { %> - - <% } %> <%= user.login %>
<%= user.login %> @@ -235,7 +227,7 @@
From 87f34adfbb97fc758fb0922ef78c71349b1fcfd5 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Mon, 1 Sep 2025 16:04:49 +0200 Subject: [PATCH 18/94] Increased readability --- src/express.ts | 55 ++++++++++++-------- src/logger.ts | 14 ------ src/services.ts | 10 ++++ src/statsd.ts | 30 ----------- src/sync.ts | 131 +++--------------------------------------------- src/util.ts | 38 -------------- views/index.ejs | 7 +++ 7 files changed, 59 insertions(+), 226 deletions(-) delete mode 100644 src/statsd.ts diff --git a/src/express.ts b/src/express.ts index 12d0432..f6f892c 100644 --- a/src/express.ts +++ b/src/express.ts @@ -5,11 +5,15 @@ import { env, ProjectStatus } from './env' import session from 'express-session' import { log } from './logger' import compression from 'compression' -// import request from 'request' import cookieParser from 'cookie-parser' import { DatabaseService } from './services' import { displayProject } from './types' +/** + * Render the error page. + * @param res The response object + * @param error The error message to display + */ async function errorPage(res: Response, error: string): Promise { const settings = { campuses: await DatabaseService.getAllCampuses(), @@ -20,7 +24,12 @@ async function errorPage(res: Response, error: string): Promise { const cachingProxy = '/proxy' -async function getUserCampusFromAPI(accessToken: string): Promise<{ campusId: number, campusName: string }> { +/** + * Get the user's campus information using the 42 API. + * @param accessToken The access token to use for authentication + * @returns The user's campus information + */ +async function getUserCampusFromAPI(accessToken: string): Promise<{ campusId: number, campusName: string | null }> { try { const response = await fetch('https://api.intra.42.fr/v2/me', { headers: { @@ -33,11 +42,13 @@ async function getUserCampusFromAPI(accessToken: string): Promise<{ campusId: nu } const userData = await response.json(); - const primaryCampus = userData.campus[0]; // User's primary campus + const primaryCampusUser = userData.campus_users.find(c => c.is_primary); + const primaryCampusId = primaryCampusUser ? primaryCampusUser.campus_id : userData.campus_users[0].campus_id; + const primaryCampus = await DatabaseService.getCampusNameById(primaryCampusId); return { - campusId: primaryCampus.id, - campusName: primaryCampus.name + campusId: primaryCampusId, + campusName: primaryCampus?.name ? primaryCampus.name : null }; } catch (error) { console.error('Error fetching user campus:', error); @@ -46,6 +57,12 @@ async function getUserCampusFromAPI(accessToken: string): Promise<{ campusId: nu } } +/** + * Get the projects for a specific campus and status. + * @param campusId The ID of the campus + * @param requestedStatus The status of the projects to retrieve + * @returns A list of projects for the specified campus and status, sorted on status. + */ async function getProjects(campusId: number, requestedStatus: string | undefined): Promise { const projectList = await DatabaseService.getAllProjects(); if (!projectList.length) { @@ -73,6 +90,10 @@ async function getProjects(campusId: number, requestedStatus: string | undefined })); } +/** + * Start the web server. + * @param port The port to listen on + */ export async function startWebserver(port: number) { const app = express() @@ -89,20 +110,7 @@ export async function startWebserver(port: number) { app.use(passport.initialize()) app.use(passport.session()) - - - // app.use(cachingProxy, (req, res) => { - // const url = req.query['q'] - // if (!url || typeof url !== 'string' || !url.startsWith('http')) { - // res.status(404).send('No URL provided') - // return - // } - - // // inject cache header for images - // res.setHeader('Cache-Control', `public, max-age=${100 * 24 * 60 * 60}`) - // req.pipe(request(url)).pipe(res) - // }) - + // Caching proxy app.use(cachingProxy, async (req, res) => { const url = req.query['q'] if (!url || typeof url !== 'string' || !url.startsWith('http')) { @@ -136,8 +144,7 @@ export async function startWebserver(port: number) { } }) - - + // Compression middleware app.use((req, res, next) => { try { compression()(req, res, next) @@ -148,11 +155,13 @@ export async function startWebserver(port: number) { next() }) + // Robots.txt app.get('/robots.txt', (_, res) => { res.type('text/plain') res.send('User-agent: *\nAllow: /') }) + // Authentication routes app.get(`/auth/${env.provider}/`, passport.authenticate(env.provider, { scope: env.scope })) app.get( `/auth/${env.provider}/callback`, @@ -162,6 +171,7 @@ export async function startWebserver(port: number) { }) ) + // Main route app.get('/', authenticate, async (req, res) => { const user = req.user as any; const accessToken = user?.accessToken; @@ -173,6 +183,7 @@ export async function startWebserver(port: number) { res.redirect(`/${await getUserCampusFromAPI(accessToken).then(data => data.campusName)}`); }) + // Campus-specific route app.get('/:campus', authenticate, async (req, res) => { const user = req.user as any; const accessToken = user?.accessToken; @@ -195,6 +206,7 @@ export async function startWebserver(port: number) { return errorPage(res, `Unknown status ${req.query['status']}`) } + // Get all necessary data to be displayed to the user const userTimeZone = req.cookies.timezone || 'Europe/Amsterdam' const settings = { projects: await getProjects(campusId, requestedStatus), @@ -211,6 +223,7 @@ export async function startWebserver(port: number) { res.render('index.ejs', settings) }) + // Monitoring route app.get('/status/pull', async (_, res) => { const lastSync = await DatabaseService.getLastSyncTimestamp(); res.json(lastSync); diff --git a/src/logger.ts b/src/logger.ts index 046663d..f027258 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,19 +1,5 @@ import { env, Env } from './env' -// eg. 24 -export function msToHuman(milliseconds: number): string { - const hours = milliseconds / (1000 * 60 * 60) - const h = Math.floor(hours) - - const minutes = (hours - h) * 60 - const m = Math.floor(minutes) - - const seconds = (minutes - m) * 60 - const s = Math.floor(seconds) - - return `${String(h).padStart(2, '0')}h ${String(m).padStart(2, '0')}m ${String(s).padStart(2, '0')}s` -} - export function nowISO(d: Date | number = new Date()): string { d = new Date(d) return `${d.toISOString().slice(0, -5)}Z` diff --git a/src/services.ts b/src/services.ts index 646270e..a9c4cdc 100644 --- a/src/services.ts +++ b/src/services.ts @@ -3,6 +3,11 @@ import { log } from 'console'; const prisma = new PrismaClient(); +/** + * Get a user-friendly error message from an error object. + * @param error The error object to extract the message from. + * @returns A user-friendly error message. + */ function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; @@ -73,6 +78,11 @@ export class DatabaseService { }); } + /** + * Get the ID of a campus by its name. + * @param campus_name The campus name to look for. + * @returns The ID of the campus. + */ static async getCampusIdByName(campus_name: string | undefined): Promise { if (!campus_name) return -1; diff --git a/src/statsd.ts b/src/statsd.ts deleted file mode 100644 index d675faf..0000000 --- a/src/statsd.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { StatsD as StatsDObj } from 'hot-shots' - -const client = new StatsDObj({ - port: 8125, - host: 'datadog-agent', // TODO use env - errorHandler: console.error, -}) - -export function increment(stat: string, tag?: string): void { - if (!isValidDataDogStr(stat)) { - return console.error(`Invalid stat ${stat}`) - } - if (tag && !isValidDataDogStr(tag)) { - return console.error(`Invalid tag ${tag} for stat ${stat}`) - } - client.increment(stat, tag ? [tag] : []) -} - -export function strToTag(prefix: string, str: string): string { - const normalized = str - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9]/g, '_') - return `${prefix}:${normalized}` -} - -function isValidDataDogStr(tag: string): boolean { - return /^[a-z0-9_:]+$/.test(tag) -} diff --git a/src/sync.ts b/src/sync.ts index 4a0fc02..ed9cae2 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -4,10 +4,6 @@ import { syncData, syncDataCB } from "./wrapper"; import { transformApiCampusToDb, transformApiProjectUserToDb, transformApiUserToDb, transformApiProjectToDb } from './transform'; import { DatabaseService } from './services'; import { log } from "./logger"; -// import path from "path"; -// import fs from "fs"; - - const fast42Api = new Fast42( [ @@ -73,7 +69,7 @@ export const syncWithIntra = async function(): Promise { */ async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined): Promise { return new Promise((resolve, reject) => { - const batches: any[][] = []; // Store all batches first + const batches: any[][] = []; const callback = async (projectUsers: any[]) => { try { @@ -81,7 +77,6 @@ async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date | undefi log(2, 'No project users found to sync.'); return; } - // Just collect batches, don't process yet batches.push(projectUsers); log(2, `Collected batch of ${projectUsers.length} project users...`); } catch (error) { @@ -94,48 +89,40 @@ async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date | undefi { 'page[size]': '100', 'filter[campus]': '14' }, callback) .then(async () => { log(2, `Collected ${batches.length} batches. Processing sequentially...`); - - // NOW process all batches sequentially using for...of let batchIndex = 0; for (const batch of batches) { batchIndex++; log(2, `Processing batch ${batchIndex}/${batches.length} (${batch.length} project users)...`); - // Process this batch sequentially const missingProjects = await DatabaseService.getMissingProjects(batch); if (missingProjects.length > 0) { log(2, `Found ${missingProjects.length} missing projects, syncing...`); await syncProjects(missingProjects); } - const missingUserIds = await DatabaseService.getMissingUserIds(batch); if (missingUserIds.length > 0) { log(2, `Found ${missingUserIds.length} missing users, syncing...`); - await syncUsersCB(fast42Api, lastPullDate, missingUserIds); + await syncUsers(fast42Api, lastPullDate, missingUserIds); } - - // Double-check missing projects and users const stillMissingProjects = await DatabaseService.getMissingProjects(batch); const stillMissingUsers = await DatabaseService.getMissingUserIds(batch); - if (stillMissingProjects.length > 0) { console.error(`ERROR: Still missing ${stillMissingProjects.length} projects after sync:`, stillMissingProjects); await syncProjects(stillMissingProjects); } - if (stillMissingUsers.length > 0) { console.error(`ERROR: Still missing ${stillMissingUsers.length} users after sync:`, stillMissingUsers); - await syncUsersCB(fast42Api, lastPullDate, stillMissingUsers); + await syncUsers(fast42Api, lastPullDate, stillMissingUsers); } - log(2, `-------Inserting ${batch.length} project users...`); + log(2, `Inserting ${batch.length} project users...`); const dbProjectUsers = batch.map(transformApiProjectUserToDb); await DatabaseService.insertManyProjectUsers(dbProjectUsers); } - log(2, 'Finished syncing project users with sequential batch method.'); + log(2, 'Finished syncing project users.'); resolve(); }) .catch((error) => { @@ -144,73 +131,17 @@ async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date | undefi }); }); } -// async function syncProjectUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined): Promise { -// return new Promise((resolve, reject) => { -// const callback = async (projectUsers: any[]) => { -// try { -// if (projectUsers.length === 0) { -// console.log('No project users found to sync.'); -// return; -// } -// console.log(`Processing batch of ${projectUsers.length} project users...`); - -// // If any project doesn't exist in the 'project' table, create an entry in 'project' table. -// const missingProjects = await DatabaseService.getMissingProjects(projectUsers); -// if (missingProjects.length > 0) { -// console.log(`Found ${missingProjects.length} missing projects, syncing...`); -// await syncProjects(missingProjects); -// } - -// // If any projectUser doesn't exist in the 'user' table, create an entry in 'user' table. -// const missingUserIds = await DatabaseService.getMissingUserIds(projectUsers); -// if (missingUserIds.length > 0) { -// console.log(`Found ${missingUserIds.length} missing users, syncing...`); -// await syncUsersCB(fast42Api, lastPullDate, missingUserIds); -// } - -// log(2, `-------Inserting ${projectUsers.length} project users...`); -// if (projectUsers.length > 1) { -// const dbProjectUsers = projectUsers.map(transformApiProjectUserToDb); -// await DatabaseService.insertManyProjectUsers(dbProjectUsers); -// } else if (projectUsers.length === 1) { -// const dbProjectUser = transformApiProjectUserToDb(projectUsers[0]); -// await DatabaseService.insertProjectUser(dbProjectUser); -// } else if (!Array.isArray(projectUsers)) { -// const dbProjectUser = transformApiProjectUserToDb(projectUsers); -// await DatabaseService.insertProjectUser(dbProjectUser); -// } -// } catch (error) { -// console.error('Failed to process project users batch:', error); -// throw error; -// } -// }; - -// syncDataCB(fast42Api, new Date(), lastPullDate, '/projects_users', -// { 'page[size]': '100', 'filter[campus]': '14' }, callback) -// .then(() => { -// console.log('Finished syncing project users with callback method.'); -// resolve(); -// }) -// .catch((error) => { -// console.error('Failed to sync project users with callback:', error); -// reject(error); -// }); -// }); -// } /** - * Sync users with the Fast42 API using a callback. + * Sync users with the Fast42 API. * @param fast42Api The Fast42 API instance to use for fetching project users * @param lastPullDate The date of the last synchronization * @returns A promise that resolves when the synchronization is complete */ -async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined, userIds: number[]): Promise { - // Process users ONE AT A TIME instead of all at once +async function syncUsers(fast42Api: Fast42, lastPullDate: Date | undefined, userIds: number[]): Promise { for (const userId of userIds) { try { log(2, `Processing missing user ${userId}...`); - - // WAIT for this API call to complete before moving to next user const userApi = await syncData(fast42Api, new Date(), lastPullDate, `/users/${userId}`, {}); const user = userApi[0]; @@ -221,11 +152,10 @@ async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined, us const dbUser = transformApiUserToDb(user); - // Check for missing campus for THIS user only const missingCampusId = await DatabaseService.getMissingCampusId(user); if (missingCampusId !== null) { log(2, `Found missing campus ID ${missingCampusId}, syncing...`); - // GUARANTEED: Campus insert completes BEFORE user insert + try { await syncCampus(fast42Api, lastPullDate, missingCampusId); } catch (error) { @@ -234,59 +164,14 @@ async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined, us dbUser.primary_campus_id = 1; // Assign to Ghost Campus } } - // SAFE: Campus definitely exists in database by now await DatabaseService.insertUser(dbUser); } catch (error) { console.error(`Failed to process user ${userId}:`, error); - // Continue with next user instead of failing completely } } log(2, 'Syncing Users completed.'); } -// async function syncUsersCB(fast42Api: Fast42, lastPullDate: Date | undefined, userIds: number[]): Promise { -// const promises = userIds.map(userId => { -// return new Promise((resolve, reject) => { -// const callback = async (user: any) => { -// try { -// if (!user) { -// console.log('No users found to sync.'); -// return; -// } -// console.log(`-----------Processing missing user ${user.id}...`); - -// // If any projectUser doesn't exist in the 'user' table, create an entry in 'user' table. -// const missingCampusId = await DatabaseService.getMissingCampusId(user); -// log(2, `-----------Missing Campus ID for user ${user.id}: ${missingCampusId}`); -// if (missingCampusId !== null) { -// console.log(`------------------Found missing campus ID ${missingCampusId}, syncing...`); -// await syncCampus(fast42Api, lastPullDate, missingCampusId); -// } - -// const dbUser = transformApiUserToDb(user); -// await DatabaseService.insertUser(dbUser); -// } catch (error) { -// console.error('Failed to process project users batch:', error); -// throw error; -// } -// }; - -// // Fetch user for each userId -// syncDataCB(fast42Api, new Date(), lastPullDate, `/users/${userId}`, -// { 'page[size]': '100' }, callback) -// .then(() => { -// console.log('Finished syncing users with callback method.'); -// resolve(undefined); -// }) -// .catch((error) => { -// console.error('Failed to sync users with callback:', error); -// reject(error); -// }); -// }); -// }); -// await Promise.all(promises); -// console.log('Syncing Users completed.'); -// } /** * Sync campuses with the Fast42 API. diff --git a/src/util.ts b/src/util.ts index 211a900..187f515 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,32 +1,3 @@ -export function findLast(arr: T[], predicate: (x: T) => boolean): T | undefined { - for (let i = arr.length - 1; i >= 0; i--) { - if (predicate(arr[i] as T)) { - return arr[i] - } - } - return undefined -} - -// get unique elements in array based on equalFn() -export function unique(arr: T[], equalFn: (a: T, b: T) => boolean): T[] { - return arr.filter((current, pos) => arr.findIndex(x => equalFn(x, current)) === pos) -} - -// ignoring case, whitespace, -, _, non ascii chars -export function isLinguisticallySimilar(a: string, b: string): boolean { - a = a - .toLowerCase() - .replace(/\s|-|_/g, '') - .normalize('NFKD') - .replace(/[\u0300-\u036F]/g, '') - b = b - .toLowerCase() - .replace(/\s|-|_/g, '') - .normalize('NFKD') - .replace(/[\u0300-\u036F]/g, '') - return a === b -} - function assertEnv(env: string): string { const value = process.env[env] if (value === undefined) { @@ -51,12 +22,3 @@ export function assertEnvInt(env: string): number { } return num } - -export function mapObject(object: Record, mapFn: (key: Key, value: Value) => NewValue): Record { - const newObj: Record = {} as Record - - for (const key in object) { - newObj[key] = mapFn(key, object[key]) - } - return newObj -} diff --git a/views/index.ejs b/views/index.ejs index d98c506..4c0e7c4 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -168,6 +168,8 @@ Built by Joppe Koers/@jkoers - Source
This page is updated every <%= updateEveryHours %> hours, last update was on <%= lastUpdate %>: <%= hoursAgo %> hours ago
+ + +
<% projects.forEach(project=>{ %>
@@ -203,6 +208,7 @@ <%= project.users.length %> users
+
<% if (project.users.length == 0 && !requestedStatus) { %>

No users are subscribed to this project

@@ -226,6 +232,7 @@ <% }) %>
+ From 9497b8d9fd4ac8e956e7da87417b0907a52682d0 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Mon, 1 Sep 2025 16:36:28 +0200 Subject: [PATCH 19/94] Gave the site an updated (Codam Inspired) look --- views/index.ejs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/views/index.ejs b/views/index.ejs index 4c0e7c4..1b01525 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -13,6 +13,8 @@ font-family: monospace; padding: 0; + --accent: #ffffff; + --search-height: 50px; --root-padding: 32px; @@ -46,18 +48,29 @@ } .project { - margin: 80px 0px; + margin: 40px 0; + padding: 32px; + background: #350f32; + border-radius: 16px; + box-shadow: 0 4px 16px rgba(0,0,0,0.2); + border: 1px solid #222; } .project-name { + margin-bottom: 16px; font-size: var(--font-size-2); - display: flex; - flex-direction: row; - align-items: flex-end; + font-weight: bold; + color: #4a98c5; } - .project-title:hover { + .project-title { cursor: pointer; + color: var(--accent); + transition: color 0.2s; + } + + .project-title:hover { + color: #4a98c5; text-decoration: underline; } @@ -107,8 +120,9 @@ } .n-users { + color: #aaa; font-size: var(--font-size-4); - padding: 5px 20px; + margin-left: 16px; } #search-bar { @@ -121,7 +135,7 @@ right: 0; padding: 0 var(--root-padding); height: var(--search-height); - background: black; + background: rgb(111, 26, 114); box-shadow: 0 0 3px 0px black; font-size: var(--font-size-3); z-index: 2; From bc9be2776cfb8584bbe20f5f3355b815a29da05c Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 2 Sep 2025 09:02:40 +0200 Subject: [PATCH 20/94] Added a short site description at the top of the page --- views/index.ejs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/views/index.ejs b/views/index.ejs index 1b01525..eb6ef6b 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -181,6 +181,10 @@
Built by Joppe Koers/@jkoers - Source
This page is updated every <%= updateEveryHours %> hours, last update was on <%= lastUpdate %>: <%= hoursAgo %> hours ago
+
+ This page displays what projects 42 students are working on, allowing them to find peers to collaborate with.
+ You can filter projects by campus, project status, and -name using the search bar above.
+ Clicking on a project or user will take you to their respective intra page.
From 29b1d8dafc92f94026bc9fddd073d57eb92c068c Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 2 Sep 2025 13:11:22 +0200 Subject: [PATCH 21/94] Removed unnecessary variable --- src/express.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/express.ts b/src/express.ts index f6f892c..752c1da 100644 --- a/src/express.ts +++ b/src/express.ts @@ -210,7 +210,6 @@ export async function startWebserver(port: number) { const userTimeZone = req.cookies.timezone || 'Europe/Amsterdam' const settings = { projects: await getProjects(campusId, requestedStatus), - users: await DatabaseService.getUsersByCampus(campusId), lastUpdate: await DatabaseService.getLastSyncTimestamp().then(date => date ? date.toLocaleString('en-NL', { timeZone: userTimeZone }).slice(0, -3) : 'N/A'), hoursAgo: (((Date.now()) - await DatabaseService.getLastSyncTimestamp().then(date => date ? date.getTime() : 0)) / (1000 * 60 * 60)).toFixed(2), // hours ago requestedStatus, From b67e4715ff2777064ca3682c8bba69abb626c3f4 Mon Sep 17 00:00:00 2001 From: Noah Mattos Oudejans Date: Tue, 2 Sep 2025 13:36:32 +0200 Subject: [PATCH 22/94] Added an option to hide empty projects --- src/express.ts | 12 +++++++++--- views/index.ejs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/express.ts b/src/express.ts index 752c1da..492be87 100644 --- a/src/express.ts +++ b/src/express.ts @@ -63,7 +63,7 @@ async function getUserCampusFromAPI(accessToken: string): Promise<{ campusId: nu * @param requestedStatus The status of the projects to retrieve * @returns A list of projects for the specified campus and status, sorted on status. */ -async function getProjects(campusId: number, requestedStatus: string | undefined): Promise { +async function getProjects(campusId: number, requestedStatus: string | undefined, hideEmptyProjects: boolean): Promise { const projectList = await DatabaseService.getAllProjects(); if (!projectList.length) { return []; @@ -84,10 +84,13 @@ async function getProjects(campusId: number, requestedStatus: string | undefined return a.login < b.login ? -1 : 1 }) }))); - return projectsWithUsers.map(project => ({ + const filteredProjects = projectsWithUsers.map(project => ({ ...project, users: project.users.filter(user => !user.login.match(/^3b3/) && !user.login.match(/^3c3/)) })); + return hideEmptyProjects + ? filteredProjects.filter(project => project.users.length > 0) + : filteredProjects; } /** @@ -206,10 +209,12 @@ export async function startWebserver(port: number) { return errorPage(res, `Unknown status ${req.query['status']}`) } + const hideEmptyProjects: boolean = req.query['hideEmptyProjects'] === '1'; + // Get all necessary data to be displayed to the user const userTimeZone = req.cookies.timezone || 'Europe/Amsterdam' const settings = { - projects: await getProjects(campusId, requestedStatus), + projects: await getProjects(campusId, requestedStatus, hideEmptyProjects), lastUpdate: await DatabaseService.getLastSyncTimestamp().then(date => date ? date.toLocaleString('en-NL', { timeZone: userTimeZone }).slice(0, -3) : 'N/A'), hoursAgo: (((Date.now()) - await DatabaseService.getLastSyncTimestamp().then(date => date ? date.getTime() : 0)) / (1000 * 60 * 60)).toFixed(2), // hours ago requestedStatus, @@ -218,6 +223,7 @@ export async function startWebserver(port: number) { campuses: await DatabaseService.getAllCampuses(), updateEveryHours: (env.pullTimeout / 1000 / 60 / 60).toFixed(0), userNewStatusThresholdDays: env.userNewStatusThresholdDays, + hideEmptyProjects, } res.render('index.ejs', settings) }) diff --git a/views/index.ejs b/views/index.ejs index eb6ef6b..8d5f60b 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -212,10 +212,27 @@