Skip to content

feat: add author achievements system#169

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

feat: add author achievements system#169
bbornino wants to merge 5 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 with automatic badge granting based on author content metrics and optional GitHub activity, plus support for manually granted achievements.
    • Added an author details endpoint that returns an author profile along with their granted achievements.
    • Updated author/post/collection sync flows to automatically recalculate achievements when related data changes.
  • Chores
    • Added database storage for granted achievements and corresponding ORM relations.
    • Updated the environment template with an optional GitHub token for GitHub-based achievement calculations.

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, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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 reviews.

How do review limits work?

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

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 11176e46-2057-4738-8336-359dc97d028c

📥 Commits

Reviewing files that changed from the base of the PR and between 75802fb and 63be87b.

📒 Files selected for processing (1)
  • apps/worker/test-utils/setup.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

Comment thread apps/api/src/routes/content/authors.ts Outdated

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 thread apps/api/src/routes/content/authors.ts Outdated
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

…edupe contributorYears

API now returns raw { id, grantedAt } per achievement instead of resolving
name/body server-side, and contributorYears() is extracted into a shared
github-api module instead of being duplicated in the worker.

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

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{}

@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: 1

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

78-81: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Achievement ordering is not deterministic.

The mapped achievements array relies on whatever order the DB relation returns. Since the frontend will render these (per the move of display logic to frontend), consider sorting by grantedAt for a stable, predictable order.

🤖 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 78 - 81, The
`authors.ts` achievement mapping currently preserves whatever order
`profile.achievements` arrives in, so make the order deterministic before
returning it. Update the `achievements` mapping in the author route to sort by
`grantedAt` first, then map to the `{ id, grantedAt }` shape, so the frontend
always receives a stable sequence.

73-76: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unvalidated cast of profile.meta jsonb column.

profile.meta is cast to a shape with optional socials/roles without any runtime validation. If stored data doesn't match (e.g., socials values aren't strings), this fails silently or produces malformed responses.

♻️ Optional: validate with TypeBox before use
-			const meta = profile.meta as {
-				socials?: Record<string, string>;
-				roles?: string[];
-			};
+			const MetaSchema = Type.Object({
+				socials: Type.Optional(Type.Record(Type.String(), Type.String())),
+				roles: Type.Optional(Type.Array(Type.String())),
+			});
+			const meta = Value.Check(MetaSchema, profile.meta)
+				? profile.meta
+				: {};
🤖 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 73 - 76, The
`profile.meta` cast in `authors.ts` is unvalidated, so replace the direct `as {
socials?: Record<string, string>; roles?: string[] }` usage with runtime
validation before building the response. Use the existing author/profile
handling path in the route to validate `meta` with TypeBox (or equivalent schema
checks) and only read `socials`/`roles` from validated data, falling back safely
when the stored jsonb shape is invalid.

33-99: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test coverage for the new route.

This is a new externally-facing endpoint (404 path, achievements mapping, image URL conversion) without an accompanying test file in the provided context.

🤖 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 33 - 99, Add test
coverage for authorsRoutes in the content authors route module, since
/content/authors/:slug is externally facing and has multiple branches to verify.
Create tests that exercise the handler in authorsRoutes for the 404 path when
db.query.profiles.findFirst returns nothing, and the success path where profile
data is mapped into AuthorResponse. Assert achievements are transformed from
achievements records to the expected id/grantedAt shape, and that
profileImageUrl is produced via createImageUrl when profileImage is present.
apps/worker/test-utils/setup.ts (1)

101-104: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

getAuthorGitHubStats is left unmocked — tests rely on the missing GITHUB_TOKEN to short-circuit.

The mock spreads importOriginal() and only overrides getContents, getContentsRaw, and getContentsRawStream. getAuthorGitHubStats (used by the achievement worker) falls through to the real implementation, which only avoids a live network call because env.GITHUB_TOKEN happens to be unset in the test environment. This makes GitHub-derived achievement test paths (commitsInYear, issueCount, pullRequestCount) untestable with deterministic data, and risks an accidental real GraphQL call if a developer has GITHUB_TOKEN set locally when running tests.

♻️ Suggested explicit mock
 vi.mock("`@playfulprogramming/github-api`", async (importOriginal) => {
 	const actual = await importOriginal();
 	return {
 		...(actual as object),
+		getAuthorGitHubStats: vi.fn().mockResolvedValue(undefined),
 		getContents: vi.fn(),

Tests that need specific GitHub stats can then override the mock per-test with vi.mocked(getAuthorGitHubStats).mockResolvedValueOnce(...).

🤖 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/test-utils/setup.ts` around lines 101 - 104, The GitHub API test
mock in setup.ts leaves getAuthorGitHubStats using the real implementation, so
achievement-worker tests depend on GITHUB_TOKEN being absent and cannot set
deterministic stats. Update the vi.mock("`@playfulprogramming/github-api`")
factory to explicitly mock getAuthorGitHubStats alongside
getContents/getContentsRaw/getContentsRawStream, and keep it overridable in
tests via vi.mocked(getAuthorGitHubStats).mockResolvedValueOnce(...) so paths
like commitsInYear, issueCount, and pullRequestCount can be exercised reliably.
🤖 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/sync-post/processor.ts`:
- Around line 43-56: The enqueue step in sync-post processing is not retry-safe
because `removedAuthorRows` is only kept in memory after `postAuthors`/post
mutations, so a failure in `createJob` can lose the affected author slugs on
retry. Update `processor.ts` (and the related logic around the `postAuthors`
replacement flow) to persist the impacted author slugs as part of the same DB
mutation or transactional outbox, so retries can re-enqueue
`Tasks.GRANT_AUTHOR_ACHIEVEMENTS` even after the join rows are removed. Make
sure the retryable state is tied to the mutation that changes the post/author
associations, not just local variables like `previousAuthorSlugs` or
`removedAuthorRows`.

---

Nitpick comments:
In `@apps/api/src/routes/content/authors.ts`:
- Around line 78-81: The `authors.ts` achievement mapping currently preserves
whatever order `profile.achievements` arrives in, so make the order
deterministic before returning it. Update the `achievements` mapping in the
author route to sort by `grantedAt` first, then map to the `{ id, grantedAt }`
shape, so the frontend always receives a stable sequence.
- Around line 73-76: The `profile.meta` cast in `authors.ts` is unvalidated, so
replace the direct `as { socials?: Record<string, string>; roles?: string[] }`
usage with runtime validation before building the response. Use the existing
author/profile handling path in the route to validate `meta` with TypeBox (or
equivalent schema checks) and only read `socials`/`roles` from validated data,
falling back safely when the stored jsonb shape is invalid.
- Around line 33-99: Add test coverage for authorsRoutes in the content authors
route module, since /content/authors/:slug is externally facing and has multiple
branches to verify. Create tests that exercise the handler in authorsRoutes for
the 404 path when db.query.profiles.findFirst returns nothing, and the success
path where profile data is mapped into AuthorResponse. Assert achievements are
transformed from achievements records to the expected id/grantedAt shape, and
that profileImageUrl is produced via createImageUrl when profileImage is
present.

In `@apps/worker/test-utils/setup.ts`:
- Around line 101-104: The GitHub API test mock in setup.ts leaves
getAuthorGitHubStats using the real implementation, so achievement-worker tests
depend on GITHUB_TOKEN being absent and cannot set deterministic stats. Update
the vi.mock("`@playfulprogramming/github-api`") factory to explicitly mock
getAuthorGitHubStats alongside getContents/getContentsRaw/getContentsRawStream,
and keep it overridable in tests via
vi.mocked(getAuthorGitHubStats).mockResolvedValueOnce(...) so paths like
commitsInYear, issueCount, and pullRequestCount can be exercised reliably.
🪄 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: 0587c641-6d44-4caf-9fe9-832147f3f0aa

📥 Commits

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

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

Comment on lines +43 to +56
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 },
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make achievement enqueueing retry-safe after mutating postAuthors.

removedAuthorRows / previousAuthorSlugs only live in memory, but the post delete and postAuthors replacement happen before BullMQ enqueueing. If createJob throws after those DB writes, the sync job retries after the old join rows are already gone, so removed authors may never be requeued and their achievements stay stale. Persist the affected author slugs with the DB mutation, e.g. via a transactional outbox, or otherwise carry them in retryable state before deleting/replacing the associations.

Also applies to: 147-219

🤖 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 43 - 56, The
enqueue step in sync-post processing is not retry-safe because
`removedAuthorRows` is only kept in memory after `postAuthors`/post mutations,
so a failure in `createJob` can lose the affected author slugs on retry. Update
`processor.ts` (and the related logic around the `postAuthors` replacement flow)
to persist the impacted author slugs as part of the same DB mutation or
transactional outbox, so retries can re-enqueue
`Tasks.GRANT_AUTHOR_ACHIEVEMENTS` even after the join rows are removed. Make
sure the retryable state is tied to the mutation that changes the post/author
associations, not just local variables like `previousAuthorSlugs` or
`removedAuthorRows`.

Previously fell through to the real implementation via importOriginal,
so tests only avoided live GitHub API calls because GITHUB_TOKEN
happened to be unset. Mock it directly so test behavior doesn't
depend on local environment state.

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

@fennifith fennifith 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.

This looks awesome! Have a couple more changes to request, then it should be good to merge!


if (!profile) {
console.log(
`grant-author-achievements: profile ${profileSlug} not found, skipping.`,

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.

Can we make this throw an error instead of logging so that it marks the job as failed?

@@ -0,0 +1,6 @@
export interface GrantAuthorAchievementsInput {
profileSlug: string;
ref: string;

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.

Can we remove ref from the input?

(The only purpose of ref is to import draft posts from PRs/branches - I can't imagine it being used for achievements)

// Word count per post (English locale only, matching frontend behaviour).
// We take the max across versions since postData has a composite PK of
// (slug, locale, version).
const wordCountRows = await db

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.

Could we modify this logic to only include published posts/collections? (where postData.publishedAt is not null)

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

3 participants