Skip to content

feat: add author achievements system#169

Open
bbornino wants to merge 3 commits into
playfulprogramming:mainfrom
bbornino:feat/author-achievements-backend
Open

feat: add author achievements system#169
bbornino wants to merge 3 commits into
playfulprogramming:mainfrom
bbornino:feat/author-achievements-backend

Conversation

@bbornino

@bbornino bbornino commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a system for tracking and surfacing author achievements:

  • New profile_achievements table (drizzle migration 20260620230617_thankful_angel) storing manual and auto-computed achievements per author.
  • New grant-author-achievements worker task that evaluates GitHub/content-stat-based achievement rules (post count, word count, co-authorship, collection count, GitHub stats) and writes the earned set, leaving manually-granted achievements untouched.
  • sync-author, sync-collection, and sync-post processors now enqueue a grant-author-achievements job (deduped by author slug) whenever an author's profile, collection, or post is synced.
  • Manual achievement IDs continue to come from author frontmatter (achievements field) and are persisted in the same table.
  • New getAuthorGitHubStats helper in @playfulprogramming/github-api for fetching GitHub-derived stats (requires GITHUB_TOKEN, documented in .env.example).
  • New GET authors route exposing achievement data (with a fixed display map for names/descriptions) for the frontend.

Closes #97

Reviewer notes

  • apps/worker/src/createWorker.ts also has unrelated changes in my working tree (a Windows ESM-compatibility fix for import.meta.resolve, plus a stalled event handler) — intentionally excluded from this PR and will land separately.
  • New env var: GITHUB_TOKEN (optional, only needed for GitHub-based achievement calculation) — documented in .env.example.

Test plan

  • Run worker locally, sync an author/post/collection, confirm a grant-author-achievements job is enqueued and achievements are written to profile_achievements
  • Confirm manual achievements (from author frontmatter) survive re-runs of the auto-grant job
  • Hit the new authors route and confirm achievement display data looks correct

Summary by CodeRabbit

  • New Features
    • Added an author achievements system that automatically computes and displays earned badges from author metrics (including English word counts and optional GitHub activity), with support for manually granted achievements.
    • Added a new author content endpoint to show author details along with achievements.
    • Updated post/author/collection sync to trigger achievement recalculation.
  • Chores
    • Added database tables and ORM relations for storing granted achievements.
    • Updated the environment template with an optional GitHub token placeholder.

Compute and persist GitHub/content-stat-based achievements automatically
via a new grant-author-achievements worker task, triggered whenever an
author's posts, collections, or profile metadata are synced. Manual
achievements continue to come from author meta and are stored alongside
the automatic ones in a new profile_achievements table. Exposes author
achievement data through a new GET authors route.
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@bbornino, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 35 minutes and 5 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses rolling per-developer review limits. Reviews become available again as older review attempts age out of the rolling limit window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 789061bc-4f9a-4f6a-a94f-a53f0eb7ba0b

📥 Commits

Reviewing files that changed from the base of the PR and between 18a2f88 and 5aa97df.

📒 Files selected for processing (2)
  • apps/worker/src/tasks/sync-post/processor.test.ts
  • apps/worker/src/tasks/sync-post/processor.ts
📝 Walkthrough

Walkthrough

Implements end-to-end author achievements: adds a profile_achievements DB table, a grant-author-achievements BullMQ task with tiered rule evaluation (role, content, word-count, GitHub metrics), integration into sync-author/post/collection processors for triggering, and a new GET /content/authors/:slug API endpoint that reads and renders earned achievements.

Changes

Author Achievements System

Layer / File(s) Summary
DB schema: profileAchievements table, migration, and relations
packages/db/src/schema/profiles.ts, packages/db/src/relations.ts, packages/db/drizzle/20260620230617_thankful_angel/*, apps/worker/test-utils/setup.ts
Adds profileAchievements Drizzle table with composite PK (profile_slug, achievement_id), a cascading FK to profiles.slug, a granted_at timestamp, ORM relations, migration SQL, snapshot JSON, and test mock scaffolding.
BullMQ task contract
packages/bullmq/src/tasks/grant-author-achievements.ts, packages/bullmq/src/tasks/index.ts, packages/bullmq/src/tasks/types.ts, .env.example
Defines GrantAuthorAchievementsInput/Output types, registers Tasks.GRANT_AUTHOR_ACHIEVEMENTS in the Tasks constant and TaskInputs/TaskOutputs maps, re-exports from the barrel, and adds optional GITHUB_TOKEN env placeholder.
GitHub stats utility
packages/github-api/src/getAuthorGitHubStats.ts, packages/github-api/src/index.ts
Adds getAuthorGitHubStats: builds dynamic per-year GraphQL query for commit totals, performs user-ID lookup and stats query via GitHub GraphQL API using GITHUB_TOKEN, returns undefined when token or user unavailable.
Achievement rule definitions
apps/worker/src/tasks/grant-author-achievements/achievement-ids.ts
Defines MANUAL_ACHIEVEMENT_IDS, AchievementRule types, contributorYears() helper, full ACHIEVEMENT_RULES array (role, content-count, word-count, GitHub issue/PR tiers, dynamic per-year contributor rules), and derived ALL_POSSIBLE_AUTO_IDS.
Grant-achievements worker processor
apps/worker/src/tasks/grant-author-achievements/processor.ts, apps/worker/src/index.ts
Implements BullMQ processor: computes English word-count/post-count/co-author/collection stats, calls getAuthorGitHubStats, evaluates ACHIEVEMENT_RULES, transactionally deletes auto-achievement rows and re-inserts earned ones. Registers worker in entry point.
Sync processors: manual achievements and job enqueue
apps/worker/src/tasks/sync-author/*, apps/worker/src/tasks/sync-collection/processor.ts, apps/worker/src/tasks/sync-post/*
Updates sync-author to accept and transactionally persist achievements from metadata (filtered to MANUAL_ACHIEVEMENT_IDS) then enqueue GRANT_AUTHOR_ACHIEVEMENTS. Updates sync-post and sync-collection to enqueue GRANT_AUTHOR_ACHIEVEMENTS for all touched author slugs with test mocks added.
API: GET /content/authors/:slug
apps/api/src/routes/content/authors.ts, apps/api/src/createApp.ts
Adds Fastify route with achievement display map and getAchievementDisplay() helper (handling words-words-words, YYYY-contributor badges, and named achievements), TypeBox schemas, and handler fetching profile with achievements, conditionally querying English word count, mapping to rendered display objects.

Sequence Diagram(s)

sequenceDiagram
  participant SyncProc as sync-author/post/collection
  participant BullMQ
  participant GrantProc as grant-author-achievements processor
  participant DB
  participant GitHubAPI as getAuthorGitHubStats

  SyncProc->>DB: upsert profile / post / collection
  SyncProc->>DB: upsert manual profileAchievements (sync-author only)
  SyncProc->>BullMQ: enqueue GRANT_AUTHOR_ACHIEVEMENTS { profileSlug, ref }

  BullMQ->>GrantProc: dequeue job
  GrantProc->>DB: fetch profile + word-count/post/collection stats
  GrantProc->>GitHubAPI: getAuthorGitHubStats(githubLogin)
  GitHubAPI-->>GrantProc: { issueCount, pullRequestCount, commitsInYear } | undefined
  GrantProc->>GrantProc: evaluate ACHIEVEMENT_RULES → earnedIds
  GrantProc->>DB: tx: DELETE WHERE achievementId IN ALL_POSSIBLE_AUTO_IDS
  GrantProc->>DB: tx: INSERT earned profileAchievements rows

  note over DB,GrantProc: Manual achievement rows untouched

  participant Client
  participant AuthorsAPI as GET /content/authors/:slug

  Client->>AuthorsAPI: GET /content/authors/:slug
  AuthorsAPI->>DB: fetch profile with profileAchievements
  AuthorsAPI->>DB: (if words-words-words earned) aggregate English word count
  AuthorsAPI-->>Client: AuthorResponse { achievements: [...] }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • playfulprogramming/hoof#126: The grant-author-achievements processor's word-count rule evaluation reads postData.wordCount, which was added/populated by the sync-post changes in this prior PR.
  • playfulprogramming/hoof#113: This PR adds authorsRoutes registration to the API's createApp Fastify setup, which was introduced by the retrieved PR that established the route/plugin wiring pattern.
  • playfulprogramming/hoof#148: This PR's new getAuthorGitHubStats GraphQL logic in packages/github-api depends on the GitHub API client layer, which the retrieved PR updates during the octokit migration.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add author achievements system' accurately summarizes the main change: implementing a backend-based achievement system for authors.
Linked Issues check ✅ Passed The PR fully implements all four requirements from #97: creates profile_achievements table, implements grant-author-achievements task, integrates into sync processors, and exposes achievements via GET authors route.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the author achievements system as specified in #97. No unrelated scope creep detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
apps/api/src/routes/content/authors.ts (1)

164-175: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Declare the 404 response schema for this endpoint.

The handler returns a 404 body at Line 187, but the route schema currently only documents 200.

💡 Suggested update
+const ErrorResponseSchema = Type.Object({
+	error: Type.String(),
+});
+
 fastify.get<{
 	Params: Static<typeof AuthorParamsSchema>;
 	Reply: AuthorResponse | { error: string };
 }>(
 	"/content/authors/:slug",
 	{
 		schema: {
 			description: "Fetch an author profile with their earned achievements",
 			params: AuthorParamsSchema,
 			response: {
 					description: "Successful",
 					content: {
 						"application/json": { schema: AuthorResponseSchema },
 					},
 				},
+				404: {
+					description: "Author not found",
+					content: {
+						"application/json": { schema: ErrorResponseSchema },
+					},
+				},
 			},
 		},
 	},

Also applies to: 186-188

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/content/authors.ts` around lines 164 - 175, The route
schema for the author endpoint in the schema object (with description "Fetch an
author profile with their earned achievements") only documents a 200 response,
but the handler returns a 404 status code. Add a 404 response schema declaration
to the response object following the same structure as the existing 200
response, documenting the error response body that is returned when the author
is not found.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/worker/src/tasks/grant-author-achievements/processor.ts`:
- Around line 89-96: The error handling in the github.getAuthorGitHubStats call
is suppressing transient failures and returning undefined, which causes the
achievement deletion and re-insertion logic to proceed without GitHub data,
potentially dropping previously earned achievements. Remove the catch handler
that logs a warning and returns undefined for the github.getAuthorGitHubStats
method call around line 89-95, and allow the error to propagate instead so the
operation fails completely rather than proceeding with incomplete data. Apply
the same fix to the similar error handling pattern mentioned at lines 116-134.

In `@apps/worker/src/tasks/sync-author/processor.ts`:
- Around line 97-100: The earnedManualIds variable created by filtering
authorData.achievements may contain duplicate values if the source array has
duplicates, which causes duplicate (profileSlug, achievementId) rows to be
inserted and violates the composite primary key constraint in the transaction
around lines 120-126. Deduplicate the earnedManualIds array before it is used in
the insert operation by converting it to a Set to remove duplicates and then
back to an array to ensure each achievement ID is only inserted once per
profile.

In `@apps/worker/src/tasks/sync-post/processor.ts`:
- Around line 185-195: The achievement recomputation loop only queues jobs for
authors currently associated with the post (authorSlugs), but fails to handle
authors that were removed when postAuthors was refreshed earlier in the
function. To fix this, capture the list of authors before the postAuthors
refresh occurs (around Line 176), then after the refresh completes, identify
which authors were removed by comparing the old and new author lists. Modify the
loop that calls createJob with Tasks.GRANT_AUTHOR_ACHIEVEMENTS to iterate over
both the current authorSlugs and the removed authors so that achievements are
properly recomputed for all affected authors including those no longer
associated with the post.

In `@packages/github-api/src/getAuthorGitHubStats.ts`:
- Around line 65-67: The user query in the client.graphql call directly
interpolates the githubLogin variable into the query string, which creates a
query injection vulnerability. Replace the interpolated githubLogin in the query
string with a GraphQL variable (e.g., $login), update the query to accept this
variable as a parameter, and pass the githubLogin value as part of the second
argument to the client.graphql call following the parameterized query pattern
used elsewhere in the file.

---

Nitpick comments:
In `@apps/api/src/routes/content/authors.ts`:
- Around line 164-175: The route schema for the author endpoint in the schema
object (with description "Fetch an author profile with their earned
achievements") only documents a 200 response, but the handler returns a 404
status code. Add a 404 response schema declaration to the response object
following the same structure as the existing 200 response, documenting the error
response body that is returned when the author is not found.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 93df9faa-9596-4f42-a540-3a67be645532

📥 Commits

Reviewing files that changed from the base of the PR and between 5a4aaf2 and b0e17fd.

📒 Files selected for processing (20)
  • .env.example
  • apps/api/src/createApp.ts
  • apps/api/src/routes/content/authors.ts
  • apps/worker/src/index.ts
  • apps/worker/src/tasks/grant-author-achievements/achievement-ids.ts
  • apps/worker/src/tasks/grant-author-achievements/processor.ts
  • apps/worker/src/tasks/sync-author/processor.ts
  • apps/worker/src/tasks/sync-author/types.ts
  • apps/worker/src/tasks/sync-collection/processor.ts
  • apps/worker/src/tasks/sync-post/processor.ts
  • apps/worker/test-utils/setup.ts
  • packages/bullmq/src/tasks/grant-author-achievements.ts
  • packages/bullmq/src/tasks/index.ts
  • packages/bullmq/src/tasks/types.ts
  • packages/db/drizzle/20260620230617_thankful_angel/migration.sql
  • packages/db/drizzle/20260620230617_thankful_angel/snapshot.json
  • packages/db/src/relations.ts
  • packages/db/src/schema/profiles.ts
  • packages/github-api/src/getAuthorGitHubStats.ts
  • packages/github-api/src/index.ts

Comment thread apps/worker/src/tasks/grant-author-achievements/processor.ts
Comment thread apps/worker/src/tasks/sync-author/processor.ts Outdated
Comment thread apps/worker/src/tasks/sync-post/processor.ts
Comment thread packages/github-api/src/getAuthorGitHubStats.ts
- Propagate GitHub stats errors instead of silently returning undefined
- Deduplicate manual achievement IDs before insert
- Recompute achievements for removed post authors
- Use GraphQL variable for user lookup query (prevents injection)
- Document 404 response schema on authors route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/worker/src/tasks/sync-post/processor.ts (1)

37-48: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Recompute achievements when a post is deleted (404 path).

When the post returns 404 it's deleted (Line 43) and its post_authors rows cascade away, but no GRANT_AUTHOR_ACHIEVEMENTS jobs are enqueued. The former authors lose a post yet keep stale post-count/word-count achievements — the same staleness class the rest of this PR fixes for in-place refreshes. Capture the authors before deleting and enqueue jobs for them.

💡 Suggested fix
 	if (folderResponse.data === undefined) {
 		if (folderResponse.status === 404) {
 			console.log(
 				`Post ${post} (${basePath}) returned 404 - removing from database.`,
 			);
 
+			const removedAuthorRows = await db
+				.select({ authorSlug: postAuthors.authorSlug })
+				.from(postAuthors)
+				.where(eq(postAuthors.postSlug, post));
+
 			await db.delete(posts).where(eq(posts.slug, post));
 
+			for (const { authorSlug } of removedAuthorRows) {
+				await createJob(
+					Tasks.GRANT_AUTHOR_ACHIEVEMENTS,
+					`grant-author-achievements:${authorSlug}`,
+					{ profileSlug: authorSlug, ref },
+				);
+			}
+
 			return;
 		}
 		throw new Error(`Failed to fetch post folder: ${basePath}`);
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/tasks/sync-post/processor.ts` around lines 37 - 48, In the
404 handling block where the post is deleted (the section with the console.log
and db.delete call), you need to capture the authors before deleting the post
and then enqueue achievement recomputation jobs for them. Before executing the
db.delete statement that removes the post from the posts table, query the
database to retrieve all authors associated with the post from the post_authors
table, store those author IDs, then proceed with the deletion. After the
deletion completes, iterate through the captured author IDs and enqueue a
GRANT_AUTHOR_ACHIEVEMENTS job for each author to refresh their stale achievement
counts (similar to how in-place refreshes are handled elsewhere in this PR).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@apps/worker/src/tasks/sync-post/processor.ts`:
- Around line 37-48: In the 404 handling block where the post is deleted (the
section with the console.log and db.delete call), you need to capture the
authors before deleting the post and then enqueue achievement recomputation jobs
for them. Before executing the db.delete statement that removes the post from
the posts table, query the database to retrieve all authors associated with the
post from the post_authors table, store those author IDs, then proceed with the
deletion. After the deletion completes, iterate through the captured author IDs
and enqueue a GRANT_AUTHOR_ACHIEVEMENTS job for each author to refresh their
stale achievement counts (similar to how in-place refreshes are handled
elsewhere in this PR).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 65c50a99-d35b-47a9-b269-05a03f247d25

📥 Commits

Reviewing files that changed from the base of the PR and between b0e17fd and 18a2f88.

📒 Files selected for processing (6)
  • apps/api/src/routes/content/authors.ts
  • apps/worker/src/tasks/grant-author-achievements/processor.ts
  • apps/worker/src/tasks/sync-author/processor.ts
  • apps/worker/src/tasks/sync-post/processor.test.ts
  • apps/worker/src/tasks/sync-post/processor.ts
  • packages/github-api/src/getAuthorGitHubStats.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/worker/src/tasks/grant-author-achievements/processor.ts
  • packages/github-api/src/getAuthorGitHubStats.ts
  • apps/api/src/routes/content/authors.ts
  • apps/worker/src/tasks/sync-author/processor.ts

CodeRabbit follow-up on PR playfulprogramming#169: the 404 path in sync-post deleted the
post but never re-evaluated achievements for its authors, leaving
stale post-count/word-count achievements behind. Capture the post's
authors before the delete and enqueue grant-author-achievements for
each, matching the removed-authors handling already in the normal
sync path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@crutchcorn crutchcorn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super minor things we should change before merging


type AchievementDisplay = { name: string; body: string };

const FIXED_ACHIEVEMENT_DISPLAY: Record<string, AchievementDisplay> = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think I'd rather us just send the raw achievements to the client and map them to the language there.

That way, we can handle translations in the frontend and keep our backend generic with what language is displayed in the UI (other than blog post language)

Comment on lines +206 to +210
// Only fetch total word count if the author has the words-words-words
// achievement — avoids an unnecessary join for everyone else.
const hasWordsAchievement = profile.achievements.some(
(a) => a.achievementId === "words-words-words",
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this makes a lot of sense as a defensive programming move, but I think I'd personally just have us eat the DB lookup cost for API return shape consistency. Shouldn't be too bad

Comment on lines +30 to +38
const FIRST_CONTRIBUTOR_YEAR = 2019;

function contributorYears(): number[] {
const years: number[] = [];
for (let y = FIRST_CONTRIBUTOR_YEAR; y <= new Date().getFullYear(); y++) {
years.push(y);
}
return years;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a huge deal, but this code threw me off cuz it was:

  • Duplicated
  • Made me think we were giving everyone achievements for every year

Might be worth making sure we're not duping loops and probably leave a comment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate author achievements to a bullmq task / db table

2 participants