diff --git a/cronus/routes/v1/auth.js b/cronus/routes/v1/auth.js index 9706eb5..1e5affc 100644 --- a/cronus/routes/v1/auth.js +++ b/cronus/routes/v1/auth.js @@ -8,6 +8,7 @@ const { authenticator } = require("otplib"); const { sendMail } = require("../../utils/smtpMailer"); const router = express.Router(); const auth = require("../../middleware/auth"); +const { getVisibleProfileBadge } = require("../../utils/profileBadges"); const EMAIL_CODE_TTL_MS = 5 * 60 * 1000; const EMAIL_CODE_MAX_ATTEMPTS = 5; const BCRYPT_COST = 12; @@ -907,7 +908,7 @@ router.get("/github-callback", async (req, res) => { router.get("/user", auth, async (req, res) => { try { - const [users] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, isVerified, telegram_id, github_id, isRole, social_links FROM users WHERE id = ?", [req.user.id]); + const [users] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, isVerified, telegram_id, github_id, isRole, active_profile_badge, social_links FROM users WHERE id = ?", [req.user.id]); if(!users.length) { return res.status(404).json({ message: "User not found" }); @@ -915,8 +916,10 @@ router.get("/user", auth, async (req, res) => { const userData = { ...users[0], + activeProfileBadge: await getVisibleProfileBadge(db, users[0]), social_links: users[0].social_links ? JSON.parse(users[0].social_links) : {}, }; + delete userData.active_profile_badge; res.json({ user: userData, success: true }); } catch (error) { diff --git a/cronus/routes/v1/mod-jams.js b/cronus/routes/v1/mod-jams.js index e83352d..a6b53c5 100644 --- a/cronus/routes/v1/mod-jams.js +++ b/cronus/routes/v1/mod-jams.js @@ -336,6 +336,7 @@ const formatJam = (jam) => { slug: jam.owner_slug, avatar: jam.owner_avatar, isVerified: jam.owner_isVerified, + activeProfileBadge: jam.owner_activeProfileBadge, } : null, submissions_count: Number(jam.submissions_count) || 0, votes_count: Number(jam.votes_count) || 0, @@ -344,7 +345,7 @@ const formatJam = (jam) => { const getJamBySlug = async (slug) => { const [rows] = await db.query( - `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, + `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, u.active_profile_badge AS owner_activeProfileBadge, (SELECT COUNT(*) FROM mod_jam_submissions mjs WHERE mjs.jam_id = mj.id AND mjs.status = 'submitted') AS submissions_count, ${PARTICIPANTS_COUNT_SELECT} AS participants_count, (SELECT COALESCE(SUM(COALESCE(mjv.vote_weight, 1)), 0) FROM mod_jam_votes mjv WHERE mjv.jam_id = mj.id) AS votes_count @@ -386,7 +387,7 @@ const getParticipantsCount = async (jamId) => { const getJamJury = async (jamId) => { const [rows] = await db.query( - `SELECT mjj.id, mjj.user_id, mjj.created_at, u.username, u.slug, u.avatar, u.isVerified + `SELECT mjj.id, mjj.user_id, mjj.created_at, u.username, u.slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge FROM mod_jam_jury mjj LEFT JOIN users u ON u.id = mjj.user_id WHERE mjj.jam_id = ? @@ -404,6 +405,7 @@ const getJamJury = async (jamId) => { slug: row.slug, avatar: row.avatar, isVerified: row.isVerified, + activeProfileBadge: row.activeProfileBadge, }, })); }; @@ -473,7 +475,7 @@ const getSubmissions = async ({ jamId, userId, resultsOnly = false }) => { p.slug AS project_slug, p.title AS project_title, p.summary AS project_summary, p.icon_url AS project_icon_url, p.downloads AS project_downloads, p.followers AS project_followers, p.updated_at AS project_updated_at, p.tags AS project_tags, p.color AS project_color, - u.username AS submitter_username, u.slug AS submitter_slug, u.avatar AS submitter_avatar, u.isVerified AS submitter_isVerified, + u.username AS submitter_username, u.slug AS submitter_slug, u.avatar AS submitter_avatar, u.isVerified AS submitter_isVerified, u.active_profile_badge AS submitter_activeProfileBadge, COALESCE(SUM(COALESCE(mjv.vote_weight, 1)), 0) AS votes_count, (SELECT user_vote.submission_id FROM mod_jam_votes user_vote WHERE user_vote.jam_id = mjs.jam_id AND user_vote.user_id = ? AND COALESCE(user_vote.nomination_id, 0) = 0 LIMIT 1) AS user_voted_submission_id FROM mod_jam_submissions mjs @@ -540,6 +542,7 @@ const getSubmissions = async ({ jamId, userId, resultsOnly = false }) => { slug: row.submitter_slug, avatar: row.submitter_avatar, isVerified: row.submitter_isVerified, + activeProfileBadge: row.submitter_activeProfileBadge, }, votes_count: Number(row.votes_count) || 0, nomination_votes: nominationScoresBySubmissionId.get(Number(row.id)) || {}, @@ -558,7 +561,7 @@ const getUserBySlug = async (slug) => { } const [rows] = await db.query( - "SELECT id, username, slug, avatar, isVerified FROM users WHERE slug = ? LIMIT 1", + "SELECT id, username, slug, avatar, isVerified, active_profile_badge AS activeProfileBadge FROM users WHERE slug = ? LIMIT 1", [normalizedSlug] ); @@ -727,7 +730,7 @@ router.get("/moderation", auth, async (req, res) => { try { const [rows] = await db.query( - `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified + `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, u.active_profile_badge AS owner_activeProfileBadge FROM mod_jams mj LEFT JOIN users u ON u.id = mj.owner_user_id WHERE mj.status = 'pending_review' @@ -744,7 +747,7 @@ router.get("/moderation", auth, async (req, res) => { router.get("/mine", auth, async (req, res) => { try { const [rows] = await db.query( - `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, + `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, u.active_profile_badge AS owner_activeProfileBadge, (SELECT COUNT(*) FROM mod_jam_submissions mjs WHERE mjs.jam_id = mj.id AND mjs.status = 'submitted') AS submissions_count, ${PARTICIPANTS_COUNT_SELECT} AS participants_count, (SELECT COALESCE(SUM(COALESCE(mjv.vote_weight, 1)), 0) FROM mod_jam_votes mjv WHERE mjv.jam_id = mj.id) AS votes_count @@ -790,7 +793,7 @@ router.get("/", async (req, res) => { } const [rows] = await db.query( - `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, + `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, u.active_profile_badge AS owner_activeProfileBadge, (SELECT COUNT(*) FROM mod_jam_submissions mjs WHERE mjs.jam_id = mj.id AND mjs.status = 'submitted') AS submissions_count, ${PARTICIPANTS_COUNT_SELECT} AS participants_count, (SELECT COALESCE(SUM(COALESCE(mjv.vote_weight, 1)), 0) FROM mod_jam_votes mjv WHERE mjv.jam_id = mj.id) AS votes_count diff --git a/cronus/routes/v1/notifications.js b/cronus/routes/v1/notifications.js index ff6e324..7b4a4e7 100644 --- a/cronus/routes/v1/notifications.js +++ b/cronus/routes/v1/notifications.js @@ -188,7 +188,8 @@ router.get("/", auth, async (req, res) => { u.username, u.slug, u.avatar, - u.isVerified + u.isVerified, + u.active_profile_badge AS activeProfileBadge FROM notification_events ne INNER JOIN users u ON u.id = ne.actor_user_id WHERE ne.recipient_user_id = ? @@ -237,6 +238,7 @@ router.get("/", auth, async (req, res) => { slug: actor.slug, avatar: actor.avatar, isVerified: Number(actor.isVerified || 0), + activeProfileBadge: actor.activeProfileBadge, createdAt: Number(actor.created_at), })), project: row.object_type === "project" ? (projectMap.get(String(row.object_id)) || null) : null, diff --git a/cronus/routes/v1/organizations.js b/cronus/routes/v1/organizations.js index 30302aa..1823487 100644 --- a/cronus/routes/v1/organizations.js +++ b/cronus/routes/v1/organizations.js @@ -70,6 +70,7 @@ const getMemberView = (row) => ({ slug: row.slug, avatar: row.avatar, isVerified: row.isVerified, + activeProfileBadge: row.activeProfileBadge, role: row.role, status: row.status, project_permissions: parsePermissions(row.project_permissions), @@ -331,7 +332,8 @@ router.get("/:slug", async (req, res) => { u.username, u.slug, u.avatar, - u.isVerified + u.isVerified, + u.active_profile_badge AS activeProfileBadge FROM organization_members om INNER JOIN users u ON u.id = om.user_id WHERE om.organization_id = ? @@ -396,7 +398,8 @@ router.get("/:slug/settings", auth, async (req, res) => { u.username, u.slug, u.avatar, - u.isVerified + u.isVerified, + u.active_profile_badge AS activeProfileBadge FROM organization_members om INNER JOIN users u ON u.id = om.user_id WHERE om.organization_id = ? @@ -407,7 +410,7 @@ router.get("/:slug/settings", auth, async (req, res) => { const [pendingInvites] = await db.query( `SELECT oi.id, oi.invited_user_id, oi.invited_by_user_id, oi.role, oi.project_permissions, oi.organization_permissions, oi.created_at, - u.username, u.slug, u.avatar, u.isVerified + u.username, u.slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge FROM organization_invitations oi INNER JOIN users u ON u.id = oi.invited_user_id WHERE oi.organization_id = ? AND oi.status = 'pending' @@ -439,6 +442,7 @@ router.get("/:slug/settings", auth, async (req, res) => { slug: invite.slug, avatar: invite.avatar, isVerified: invite.isVerified, + activeProfileBadge: invite.activeProfileBadge, role: invite.role, project_permissions: parsePermissions(invite.project_permissions), organization_permissions: parsePermissions(invite.organization_permissions), diff --git a/cronus/routes/v1/projects.js b/cronus/routes/v1/projects.js index 2aafae8..e3327ee 100644 --- a/cronus/routes/v1/projects.js +++ b/cronus/routes/v1/projects.js @@ -1201,7 +1201,7 @@ router.get("/", async (req, res) => { let query = ` SELECT p.id, p.slug, p.title, p.summary, p.icon_url, p.color, p.downloads, p.followers, p.created_at, p.updated_at, p.project_type, p.tags, p.license_id, p.license_name, p.show_players_last_14d, - ANY_VALUE(u.username) AS username, ANY_VALUE(u.slug) AS user_slug, ANY_VALUE(u.avatar) AS avatar, ANY_VALUE(u.id) AS user_id, ANY_VALUE(u.isVerified) AS isVerified, + ANY_VALUE(u.username) AS username, ANY_VALUE(u.slug) AS user_slug, ANY_VALUE(u.avatar) AS avatar, ANY_VALUE(u.id) AS user_id, ANY_VALUE(u.isVerified) AS isVerified, ANY_VALUE(u.active_profile_badge) AS activeProfileBadge, ANY_VALUE(o.id) AS organization_id, ANY_VALUE(o.slug) AS organization_slug, ANY_VALUE(o.name) AS organization_name, ANY_VALUE(o.icon_url) AS organization_icon_url, ANY_VALUE(o.summary) AS organization_summary, ANY_VALUE(pv.game_versions) AS game_versions, ANY_VALUE(pv.loaders) AS loaders, (SELECT url FROM project_gallery WHERE project_id = p.id AND featured = 1 LIMIT 1) AS featured_image @@ -1327,6 +1327,7 @@ router.get("/", async (req, res) => { slug: project.user_slug, avatar: project.avatar, isVerified: project.isVerified, + activeProfileBadge: project.activeProfileBadge, type: "user", profile_url: `/user/${project.user_slug}`, }, @@ -1422,6 +1423,7 @@ router.get('/user/projects', auth, async (req, res) => { u.slug AS user_slug, u.avatar, u.isVerified, + u.active_profile_badge AS activeProfileBadge, o.id AS organization_id, o.slug AS organization_slug, o.name AS organization_name, @@ -1482,6 +1484,7 @@ router.get('/user/projects', auth, async (req, res) => { slug: project.user_slug, avatar: project.avatar, isVerified: project.isVerified, + activeProfileBadge: project.activeProfileBadge, type: "user", profile_url: profileUrl, }, @@ -2191,7 +2194,7 @@ router.get('/:slug', optionalAuth, async (req, res) => { const [project] = await db.query( `SELECT p.*, - u.username, u.slug AS user_slug, u.avatar, u.id AS user_id, u.isVerified AS isVerified, + u.username, u.slug AS user_slug, u.avatar, u.id AS user_id, u.isVerified AS isVerified, u.active_profile_badge AS activeProfileBadge, (SELECT COUNT(*) FROM project_likes pl WHERE pl.project_id = p.id) AS followers_count, (SELECT 1 FROM project_likes pl WHERE pl.project_id = p.id AND pl.user_id = ?) AS is_liked FROM projects p @@ -2220,7 +2223,7 @@ router.get('/:slug', optionalAuth, async (req, res) => { ); const [members] = await db.query( - `SELECT pm.user_id, pm.role, pm.status, u.username, u.slug, u.avatar, u.isVerified + `SELECT pm.user_id, pm.role, pm.status, u.username, u.slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge FROM project_members pm LEFT JOIN users u ON pm.user_id = u.id WHERE pm.project_id = ?`, @@ -2320,6 +2323,7 @@ router.get('/:slug', optionalAuth, async (req, res) => { slug: projectData.user_slug, avatar: projectData.avatar, isVerified: projectData.isVerified, + activeProfileBadge: projectData.activeProfileBadge, type: "user", profile_url: `/user/${projectData.user_slug}`, }, @@ -2338,6 +2342,7 @@ router.get('/:slug', optionalAuth, async (req, res) => { slug: member.slug, avatar: member.avatar, isVerified: member.isVerified, + activeProfileBadge: member.activeProfileBadge, })), mod_jam_participations: modJamParticipations.map((jam) => ({ ...(() => { @@ -3056,7 +3061,7 @@ router.get('/:slug/members', async (req, res) => { } const [members] = await db.query(` - SELECT pm.user_id, pm.role, pm.status, u.username, u.slug, u.avatar, u.isVerified + SELECT pm.user_id, pm.role, pm.status, u.username, u.slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge FROM project_members pm LEFT JOIN users u ON pm.user_id = u.id WHERE pm.project_id = ? @@ -3110,7 +3115,7 @@ router.get("/:slug/issues", optionalAuth, async (req, res) => { const params = [project.id]; let query = ` SELECT i.id, i.title, i.status, i.created_at, i.updated_at, i.author_user_id, i.is_pinned, - u.username, u.slug, u.avatar, u.isVerified, u.isRole, + u.username, u.slug, u.avatar, u.isVerified, u.isRole, u.active_profile_badge AS activeProfileBadge, ( SELECT COUNT(*) FROM project_issue_comments ic WHERE ic.issue_id = i.id AND ic.status = 'visible' @@ -3177,6 +3182,7 @@ router.get("/:slug/issues", optionalAuth, async (req, res) => { avatar: issue.avatar, isVerified: issue.isVerified, isRole: issue.isRole, + activeProfileBadge: issue.activeProfileBadge, } : null, })); @@ -3669,7 +3675,7 @@ router.get("/:slug/issues/:issueId", optionalAuth, async (req, res) => { const [issues] = await db.query( `SELECT i.id, i.title, i.body, i.status, i.created_at, i.updated_at, i.closed_at, i.closed_by, i.template_id, - i.author_user_id, u.username, u.slug, u.avatar, u.isVerified, u.isRole + i.author_user_id, u.username, u.slug, u.avatar, u.isVerified, u.isRole, u.active_profile_badge AS activeProfileBadge FROM project_issues i LEFT JOIN users u ON u.id = i.author_user_id WHERE i.project_id = ? AND i.id = ? LIMIT 1`, @@ -3702,7 +3708,7 @@ router.get("/:slug/issues/:issueId", optionalAuth, async (req, res) => { const commentsQuery = ` SELECT c.id, c.parent_id, c.content, c.created_at, c.updated_at, c.status, c.user_id, - u.username, u.slug, u.avatar, u.isVerified, u.isRole + u.username, u.slug, u.avatar, u.isVerified, u.isRole, u.active_profile_badge AS activeProfileBadge FROM project_issue_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.issue_id = ? @@ -3728,13 +3734,14 @@ router.get("/:slug/issues/:issueId", optionalAuth, async (req, res) => { avatar: comment.avatar, isVerified: comment.isVerified, isRole: comment.isRole, + activeProfileBadge: comment.activeProfileBadge, }, }; }); const [eventRows] = await db.query( `SELECT e.id, e.event_type, e.created_at, e.actor_user_id, e.meta, - u.username, u.slug, u.avatar, u.isVerified, u.isRole + u.username, u.slug, u.avatar, u.isVerified, u.isRole, u.active_profile_badge AS activeProfileBadge FROM project_issue_events e LEFT JOIN users u ON u.id = e.actor_user_id WHERE e.issue_id = ? @@ -3754,6 +3761,7 @@ router.get("/:slug/issues/:issueId", optionalAuth, async (req, res) => { avatar: row.avatar, isVerified: row.isVerified, isRole: row.isRole, + activeProfileBadge: row.activeProfileBadge, } : null, })); @@ -3787,6 +3795,7 @@ router.get("/:slug/issues/:issueId", optionalAuth, async (req, res) => { avatar: issue.avatar, isVerified: issue.isVerified, isRole: issue.isRole, + activeProfileBadge: issue.activeProfileBadge, } : null, }, canManage: !!access.canManage, @@ -4274,7 +4283,7 @@ router.post("/:slug/issues/:issueId/comments", auth, async (req, res) => { [issueId, userId, parent_id || null, trimmed, now, now] ); - const [users] = await db.query("SELECT id, username, slug, avatar, isVerified, isRole FROM users WHERE id = ? LIMIT 1", [userId]); + const [users] = await db.query("SELECT id, username, slug, avatar, isVerified, isRole, active_profile_badge AS activeProfileBadge FROM users WHERE id = ? LIMIT 1", [userId]); const author = users[0]; return res.status(201).json({ @@ -4291,6 +4300,7 @@ router.post("/:slug/issues/:issueId/comments", auth, async (req, res) => { avatar: author.avatar, isVerified: author.isVerified, isRole: author.isRole, + activeProfileBadge: author.activeProfileBadge, }, }); } catch (error) { diff --git a/cronus/routes/v1/recommended.js b/cronus/routes/v1/recommended.js index 07ac182..b1a65ad 100644 --- a/cronus/routes/v1/recommended.js +++ b/cronus/routes/v1/recommended.js @@ -55,6 +55,7 @@ router.get("/", async (req, res) => { u.username, u.slug AS user_slug, u.avatar AS user_avatar, + u.active_profile_badge AS activeProfileBadge, o.slug AS organization_slug, o.name AS organization_name, o.icon_url AS organization_icon_url, @@ -87,6 +88,7 @@ router.get("/", async (req, res) => { username: project.username, slug: project.user_slug, avatar: project.user_avatar || "https://media.modifold.com/static/no-project-icon.svg", + activeProfileBadge: project.activeProfileBadge, profile_url: `/user/${project.user_slug}`, }, })), diff --git a/cronus/routes/v1/users.js b/cronus/routes/v1/users.js index 4273e98..9eaef6c 100644 --- a/cronus/routes/v1/users.js +++ b/cronus/routes/v1/users.js @@ -11,6 +11,7 @@ const fs = require("fs").promises; const path = require("path"); const { sanitizePlainText, sanitizeSocialLinks } = require("../../utils/sanitize"); const { normalizeSlugInput, validateSlug, getSlugValidationMessage } = require("../../utils/slug"); +const { getUnlockedProfileBadges, getVisibleProfileBadge, normalizeProfileBadgeCode } = require("../../utils/profileBadges"); const storage = multer.diskStorage({ destination: process.env.MEDIA_ROOT, @@ -201,12 +202,15 @@ router.put("/me", auth, upload.fields([{ name: "avatar" }, { name: "cover" }]), await db.query("UPDATE users SET ? WHERE id = ?", [updates, req.user.id]); - const [updatedUser] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, social_links FROM users WHERE id = ?", [req.user.id]); + const [updatedUser] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, isVerified, isRole, active_profile_badge, social_links FROM users WHERE id = ?", [req.user.id]); if(updatedUser[0]?.social_links) { updatedUser[0].social_links = JSON.parse(updatedUser[0].social_links); } + updatedUser[0].activeProfileBadge = await getVisibleProfileBadge(db, updatedUser[0]); + delete updatedUser[0].active_profile_badge; + res.json(updatedUser[0]); } catch (error) { console.error("Error updating user:", error); @@ -214,6 +218,47 @@ router.put("/me", auth, upload.fields([{ name: "avatar" }, { name: "cover" }]), } }); +router.put("/me/profile-badge", auth, async (req, res) => { + try { + const rawBadge = req.body?.badge; + const hasBadgeValue = rawBadge !== null && rawBadge !== undefined && String(rawBadge).trim() !== ""; + const nextBadge = normalizeProfileBadgeCode(rawBadge); + + if(hasBadgeValue && !nextBadge) { + return res.status(400).json({ message: "Invalid profile badge" }); + } + + const [users] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, isVerified, isRole, active_profile_badge, social_links FROM users WHERE id = ? LIMIT 1", [req.user.id]); + const user = users[0]; + + if(!user) { + return res.status(404).json({ message: "User not found" }); + } + + if(nextBadge) { + const unlockedBadges = await getUnlockedProfileBadges(db, user); + if(!unlockedBadges.includes(nextBadge)) { + return res.status(403).json({ message: "Profile badge is not available" }); + } + } + + await db.query("UPDATE users SET active_profile_badge = ? WHERE id = ?", [nextBadge, req.user.id]); + + const updatedUser = { + ...user, + activeProfileBadge: nextBadge, + social_links: user.social_links ? JSON.parse(user.social_links) : {}, + }; + + delete updatedUser.active_profile_badge; + + return res.json(updatedUser); + } catch (error) { + console.error("Error updating profile badge:", error); + return res.status(500).json({ message: "Error updating profile badge", error: error.message }); + } +}); + router.get("/slug-availability/:slug", auth, async (req, res) => { try { const candidateSlug = normalizeSlugInput(req.params.slug); @@ -296,6 +341,7 @@ router.get("/me/likes", auth, async (req, res) => { u.slug AS user_slug, u.avatar, u.isVerified, + u.active_profile_badge AS activeProfileBadge, o.id AS organization_id, o.slug AS organization_slug, o.name AS organization_name, @@ -358,6 +404,7 @@ router.get("/me/likes", auth, async (req, res) => { slug: project.user_slug, avatar: project.avatar, isVerified: project.isVerified, + activeProfileBadge: project.activeProfileBadge, type: "user", profile_url: `/user/${project.user_slug}`, }, @@ -397,7 +444,7 @@ router.get("/:username/projects", async (req, res) => { let query = ` SELECT p.id, p.slug, p.title, p.summary, p.icon_url, p.downloads, p.created_at, p.updated_at, p.project_type, p.tags, - u.username, u.slug AS user_slug, u.avatar, u.isVerified, + u.username, u.slug AS user_slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge, o.id AS organization_id, o.slug AS organization_slug, o.name AS organization_name, o.icon_url AS organization_icon_url, o.summary AS organization_summary, (SELECT url FROM project_gallery WHERE project_id = p.id AND featured = 1 LIMIT 1) AS featured_image FROM projects p @@ -465,6 +512,7 @@ router.get("/:username/projects", async (req, res) => { slug: project.user_slug, avatar: project.avatar, isVerified: project.isVerified, + activeProfileBadge: project.activeProfileBadge, type: "user", profile_url: `/user/${project.user_slug}`, }, @@ -507,7 +555,7 @@ router.get("/:username/follows", async (req, res) => { const whereColumn = type === "subscribers" ? "s.userid" : "s.author_id"; const [rows] = await db.query( - `SELECT u.id, u.username, u.slug, u.avatar, u.isVerified + `SELECT u.id, u.username, u.slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge FROM subs s INNER JOIN users u ON u.id = ${joinColumn} WHERE ${whereColumn} = ? AND s.type = 'user' @@ -523,6 +571,7 @@ router.get("/:username/follows", async (req, res) => { slug: item.slug, avatar: item.avatar, isVerified: item.isVerified, + activeProfileBadge: item.activeProfileBadge, })); const responseData = { @@ -634,7 +683,7 @@ router.get("/:username/achievements", async (req, res) => { router.get("/:username", async (req, res) => { try { - const [user] = await db.query("SELECT id, username, slug, description, avatar, cover, created_at, isVerified, isRole, social_links FROM users WHERE slug = ?", [req.params.username]); + const [user] = await db.query("SELECT id, username, slug, description, avatar, cover, created_at, isVerified, isRole, active_profile_badge, social_links FROM users WHERE slug = ?", [req.params.username]); if(!user.length) { return res.status(404).json({ message: "User not found" }); @@ -645,6 +694,7 @@ router.get("/:username", async (req, res) => { const [subs] = await db.query("SELECT COUNT(*) as count FROM subs WHERE userid = ?", [userId]); const [userSubs] = await db.query("SELECT COUNT(*) as count FROM subs WHERE author_id = ?", [userId]); + const activeProfileBadge = await getVisibleProfileBadge(db, user[0]); const userWithoutSensitiveData = { id: user[0].id, username: user[0].username, @@ -655,6 +705,7 @@ router.get("/:username", async (req, res) => { created_at: user[0].created_at, isVerified: user[0].isVerified, isRole: user[0].isRole, + activeProfileBadge, subscribers: subs[0].count, subscriptions: userSubs[0].count, social_links: user[0].social_links ? JSON.parse(user[0].social_links) : {}, diff --git a/cronus/utils/profileBadges.js b/cronus/utils/profileBadges.js new file mode 100644 index 0000000..ee9b910 --- /dev/null +++ b/cronus/utils/profileBadges.js @@ -0,0 +1,81 @@ +const PROFILE_BADGE_CODES = { + CREATOR: "creator_badge", + STAFF: "staff", + FIRST_PROJECT: "first_project", + MOD_JAM: "hytalemodjam_2026", + DOWNLOADS_100: "downloads_100", + DOWNLOADS_500: "downloads_500", + DOWNLOADS_10000: "downloads_10000", +}; + +const PROFILE_BADGE_CODE_SET = new Set(Object.values(PROFILE_BADGE_CODES)); +const ACHIEVEMENT_PROFILE_BADGES = [ + PROFILE_BADGE_CODES.FIRST_PROJECT, + PROFILE_BADGE_CODES.MOD_JAM, + PROFILE_BADGE_CODES.DOWNLOADS_100, + PROFILE_BADGE_CODES.DOWNLOADS_500, + PROFILE_BADGE_CODES.DOWNLOADS_10000, +]; + +const normalizeProfileBadgeCode = (value) => { + if(value === null || value === undefined || value === "") { + return null; + } + + const code = String(value).trim(); + return PROFILE_BADGE_CODE_SET.has(code) ? code : null; +}; + +const isStaffRole = (role) => role === "admin" || role === "moderator" || role === "staff"; + +const getUnlockedProfileBadges = async (db, user) => { + if(!user?.id) { + return []; + } + + const unlocked = []; + + if(Number(user.isVerified || 0) === 1) { + unlocked.push(PROFILE_BADGE_CODES.CREATOR); + } + + if(isStaffRole(user.isRole)) { + unlocked.push(PROFILE_BADGE_CODES.STAFF); + } + + const [achievementRows] = await db.query( + `SELECT a.code + FROM user_achievements ua + INNER JOIN achievements a ON a.id = ua.achievement_id + WHERE ua.user_id = ? + AND a.code IN (?) + AND a.is_active = 1`, + [user.id, ACHIEVEMENT_PROFILE_BADGES] + ); + + const awardedCodes = new Set(achievementRows.map((row) => row.code)); + for(const code of ACHIEVEMENT_PROFILE_BADGES) { + if(awardedCodes.has(code)) { + unlocked.push(code); + } + } + + return unlocked; +}; + +const getVisibleProfileBadge = async (db, user) => { + const activeBadge = normalizeProfileBadgeCode(user?.active_profile_badge || user?.activeProfileBadge); + if(!activeBadge) { + return null; + } + + const unlockedBadges = await getUnlockedProfileBadges(db, user); + return unlockedBadges.includes(activeBadge) ? activeBadge : null; +}; + +module.exports = { + PROFILE_BADGE_CODES, + getUnlockedProfileBadges, + getVisibleProfileBadge, + normalizeProfileBadgeCode, +}; \ No newline at end of file diff --git a/pegasus/app/user/[username]/page.js b/pegasus/app/user/[username]/page.js index 425f8d3..66a7fb6 100644 --- a/pegasus/app/user/[username]/page.js +++ b/pegasus/app/user/[username]/page.js @@ -46,7 +46,7 @@ export default async function Page({ params, searchParams }) { const userRes = await fetch(`${apiBase}/users/${username}`, { headers: { Accept: "application/json" }, - next: { revalidate: 60, tags: [`user:${username}`] }, + cache: "no-store", }); if(!userRes.ok) { @@ -63,12 +63,12 @@ export default async function Page({ params, searchParams }) { const banFetchOptions = { headers: { Accept: "application/json" }, - next: { revalidate: 60, tags: [`user:${username}:ban`] }, + cache: "no-store", }; const projectsFetchOptions = { headers: { Accept: "application/json" }, - next: { revalidate: 60, tags: [`user:${username}:projects:${currentProjectsPage}:${currentProjectsSort}`] }, + cache: "no-store", }; const [banRes, projectsRes, organizationsRes, achievementsRes] = await Promise.all([ @@ -76,11 +76,11 @@ export default async function Page({ params, searchParams }) { fetch(`${apiBase}/users/${username}/projects?page=${currentProjectsPage}&limit=20&sort=${currentProjectsSort}`, projectsFetchOptions), fetch(`${apiBase}/users/${username}/organizations`, { headers: { Accept: "application/json" }, - next: { revalidate: 60, tags: [`user:${username}:organizations`] }, + cache: "no-store", }), fetch(`${apiBase}/users/${username}/achievements`, { headers: { Accept: "application/json" }, - next: { revalidate: 60, tags: [`user:${username}:achievements`] }, + cache: "no-store", }), ]); diff --git a/pegasus/components/layout/Header.js b/pegasus/components/layout/Header.js index 477c0f0..0a783c7 100644 --- a/pegasus/components/layout/Header.js +++ b/pegasus/components/layout/Header.js @@ -8,6 +8,8 @@ import Link from "next/link"; import Image from "next/image"; import { useTranslations } from "next-intl"; import { toast } from "react-toastify"; +import ProfileBadgeIcon from "@/components/ui/ProfileBadgeIcon"; +import { getProfileBadgeCode } from "@/utils/profileBadges"; export default function Header({ authToken }) { const t = useTranslations("Header"); @@ -21,6 +23,7 @@ export default function Header({ authToken }) { const buttonRef = useRef(null); const browseWrapperRef = useRef(null); const browseCloseTimeoutRef = useRef(null); + const activeProfileBadgeCode = getProfileBadgeCode(user); useEffect(() => { @@ -296,8 +299,8 @@ export default function Header({ authToken }) {
+ {activeProfileBadgeCode && (
+
+ {activeProfileBadgeCode && (
+
-
- {t("verifiedModal.creatorBadgeText")}
- - - {t("verifiedModal.learnMore")} - -
+ {profileBadgeCode && showVerifiedIcon && (
+