
rewards-module-builder
by coopeverything
Building TogetherOS, an Operating System for humanity. All aspects of human cooperation will find a modern, open source, modular, evolving, empathy based platform. Clone to join! Reach out by opening an issue
SKILL.md
name: rewards-module-builder description: Automates development of TogetherOS Rewards module features. Use when building reward types, implementing validation, creating UI components, writing tests, or updating Rewards documentation. Handles end-to-end implementation from entity models through API handlers to frontend components.
Rewards Module Builder
This skill automates development of the TogetherOS Rewards module, a gamification system that recognizes contributions through badges, skill trees, and visual progression.
When to Use This Skill
Use this skill when:
- Creating new reward types or badges
- Implementing reward validation logic
- Building reward UI components
- Writing tests for reward functionality
- Updating Rewards module documentation
- Connecting rewards to member actions
Core Concepts
Reward Types
TogetherOS supports four reward categories:
- Badges - Achievement markers (e.g., "First PR Merged", "10 Mutual Aids")
- Skill Tree Nodes - Path-specific progression (Builder, Community Heart, etc.)
- Visual States - Member progression visualization (seed → seedling → young tree → majestic tree)
- Capability Unlocks - Feature access gates (e.g., create proposals, organize events)
Domain-Driven Architecture
Rewards module follows TogetherOS's standard domain-driven pattern:
apps/api/src/modules/rewards/
├── entities/ # Domain models (Badge, SkillNode, etc.)
├── repos/ # Data access interfaces + in-memory implementations
├── handlers/ # API handlers (create, award, list)
└── fixtures/ # Test data
apps/web/app/(platform)/profiles/[handle]/rewards/
├── page.tsx # Member rewards view
└── components/ # Reward display components
packages/types/src/rewards.ts # TypeScript interfaces
packages/validators/src/rewards.ts # Zod schemas
packages/ui/src/rewards/ # Shared reward components
Implementation Workflow
1. Define the Reward
Start with clear specifications:
- What triggers it? (e.g., "merge 10 PRs")
- What does it unlock? (capabilities, recognition)
- Which path? (Builder, Community Heart, etc.)
- Visual representation? (icon, color, animation)
2. Create Entity Model
// apps/api/src/modules/rewards/entities/Badge.ts
export class Badge {
constructor(
public id: string,
public name: string,
public description: string,
public icon: string,
public path: 'builder' | 'community_heart' | 'guided_contributor' | 'steady_cultivator',
public criteria: BadgeCriteria,
public createdAt: Date
) {}
static create(input: CreateBadgeInput): Badge {
// Validation logic
if (input.name.length < 3) {
throw new Error('Badge name must be at least 3 characters')
}
return new Badge(
generateId(),
input.name,
input.description,
input.icon,
input.path,
input.criteria,
new Date()
)
}
canAward(memberActivity: MemberActivity): boolean {
// Check if criteria met
return this.criteria.check(memberActivity)
}
}
3. Implement Repository
// apps/api/src/modules/rewards/repos/BadgeRepo.ts
export interface BadgeRepo {
create(input: CreateBadgeInput): Promise<Badge>
findById(id: string): Promise<Badge | null>
listByPath(path: string): Promise<Badge[]>
award(badgeId: string, memberId: string): Promise<void>
getMemberBadges(memberId: string): Promise<Badge[]>
}
// apps/api/src/modules/rewards/repos/InMemoryBadgeRepo.ts
export class InMemoryBadgeRepo implements BadgeRepo {
private badges = new Map<string, Badge>()
private awards = new Map<string, string[]>() // memberId -> badgeIds
async create(input: CreateBadgeInput): Promise<Badge> {
const badge = Badge.create(input)
this.badges.set(badge.id, badge)
return badge
}
async award(badgeId: string, memberId: string): Promise<void> {
const badge = await this.findById(badgeId)
if (!badge) throw new Error('Badge not found')
const memberBadges = this.awards.get(memberId) || []
if (!memberBadges.includes(badgeId)) {
memberBadges.push(badgeId)
this.awards.set(memberId, memberBadges)
}
}
async getMemberBadges(memberId: string): Promise<Badge[]> {
const badgeIds = this.awards.get(memberId) || []
return Promise.all(
badgeIds.map(id => this.findById(id)).filter(b => b !== null)
)
}
}
4. Create Zod Schemas
// packages/validators/src/rewards.ts
import { z } from 'zod'
export const createBadgeSchema = z.object({
name: z.string().min(3).max(50),
description: z.string().min(10).max(200),
icon: z.string().emoji().or(z.string().url()),
path: z.enum(['builder', 'community_heart', 'guided_contributor', 'steady_cultivator']),
criteria: z.object({
type: z.enum(['pr_count', 'mutual_aid_count', 'event_organized', 'custom']),
threshold: z.number().positive(),
timeframe: z.enum(['all_time', 'monthly', 'yearly']).optional(),
}),
})
export const awardBadgeSchema = z.object({
badgeId: z.string().uuid(),
memberId: z.string().uuid(),
reason: z.string().min(10).max(200),
})
5. Implement API Handler
// apps/api/src/modules/rewards/handlers/awardBadge.ts
import { awardBadgeSchema } from '@togetheros/validators'
import { BadgeRepo } from '../repos'
export async function awardBadge(
input: unknown,
repo: BadgeRepo
) {
const data = awardBadgeSchema.parse(input)
// Check if badge exists
const badge = await repo.findById(data.badgeId)
if (!badge) {
return { error: { code: 'BADGE_NOT_FOUND', message: 'Badge does not exist' } }
}
// Award badge
await repo.award(data.badgeId, data.memberId)
// Log transaction (append-only NDJSON)
await logRewardTransaction({
type: 'badge_awarded',
badgeId: data.badgeId,
memberId: data.memberId,
reason: data.reason,
timestamp: new Date().toISOString(),
})
return { success: true }
}
6. Build UI Component
// packages/ui/src/rewards/BadgeCard.tsx
import { Badge } from '@togetheros/types'
interface BadgeCardProps {
badge: Badge
earnedAt?: Date
locked?: boolean
}
export function BadgeCard({ badge, earnedAt, locked = false }: BadgeCardProps) {
return (
<div className={`
relative rounded-lg border p-4 transition-all
${locked ? 'opacity-50 grayscale' : 'hover:shadow-md'}
`}>
{/* Icon */}
<div className="text-4xl mb-2">{badge.icon}</div>
{/* Name */}
<h3 className="font-semibold text-lg">{badge.name}</h3>
{/* Description */}
<p className="text-sm text-gray-600 mt-1">{badge.description}</p>
{/* Earned date */}
{earnedAt && (
<p className="text-xs text-gray-500 mt-2">
Earned {earnedAt.toLocaleDateString()}
</p>
)}
{/* Locked overlay */}
{locked && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl">🔒</span>
</div>
)}
</div>
)
}
7. Write Tests
// apps/api/src/modules/rewards/__tests__/awardBadge.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { InMemoryBadgeRepo } from '../repos/InMemoryBadgeRepo'
import { awardBadge } from '../handlers/awardBadge'
describe('awardBadge', () => {
let repo: InMemoryBadgeRepo
beforeEach(() => {
repo = new InMemoryBadgeRepo()
})
it('awards badge to member', async () => {
// Setup
const badge = await repo.create({
name: 'First PR',
description: 'Merged your first PR',
icon: '🎉',
path: 'builder',
criteria: { type: 'pr_count', threshold: 1 },
})
// Execute
const result = await awardBadge({
badgeId: badge.id,
memberId: 'member-123',
reason: 'PR #42 merged',
}, repo)
// Assert
expect(result.success).toBe(true)
const memberBadges = await repo.getMemberBadges('member-123')
expect(memberBadges).toHaveLength(1)
expect(memberBadges[0].id).toBe(badge.id)
})
it('returns error for non-existent badge', async () => {
const result = await awardBadge({
badgeId: 'fake-id',
memberId: 'member-123',
reason: 'Test',
}, repo)
expect(result.error?.code).toBe('BADGE_NOT_FOUND')
})
it('prevents duplicate badge awards', async () => {
const badge = await repo.create({
name: 'First PR',
description: 'Merged your first PR',
icon: '🎉',
path: 'builder',
criteria: { type: 'pr_count', threshold: 1 },
})
// Award twice
await awardBadge({ badgeId: badge.id, memberId: 'member-123', reason: 'First' }, repo)
await awardBadge({ badgeId: badge.id, memberId: 'member-123', reason: 'Second' }, repo)
// Should only have one
const memberBadges = await repo.getMemberBadges('member-123')
expect(memberBadges).toHaveLength(1)
})
})
8. Create Fixtures
// packages/fixtures/src/badges.ts
export const badgeFixtures = [
{
id: 'badge-first-pr',
name: 'First PR Merged',
description: 'Congratulations on your first merged pull request!',
icon: '🎉',
path: 'builder' as const,
criteria: { type: 'pr_count' as const, threshold: 1 },
},
{
id: 'badge-10-prs',
name: '10 PRs Strong',
description: 'You've merged 10 pull requests. Impressive!',
icon: '💪',
path: 'builder' as const,
criteria: { type: 'pr_count' as const, threshold: 10 },
},
{
id: 'badge-first-mutual-aid',
name: 'Helping Hand',
description: 'Completed your first mutual aid transaction',
icon: '🤝',
path: 'community_heart' as const,
criteria: { type: 'mutual_aid_count' as const, threshold: 1 },
},
]
Transaction Logging
All reward awards must be logged to NDJSON:
// Log format
{
"id": "uuid",
"timestamp": "2025-01-15T10:30:00Z",
"event_type": "reward_awarded",
"metadata": {
"reward_type": "badge",
"reward_id": "badge-first-pr",
"member_id": "member-123",
"reason": "PR #42 merged",
"member_handle": "alice_organizer"
}
}
Store logs in: logs/rewards/transactions-YYYY-MM-DD.ndjson
Visual Progression System
Members progress through visual states based on contributions:
export type VisualState = 'seed' | 'seedling' | 'young_tree' | 'majestic_tree'
export function calculateVisualState(contributionScore: number): VisualState {
if (contributionScore < 10) return 'seed'
if (contributionScore < 50) return 'seedling'
if (contributionScore < 200) return 'young_tree'
return 'majestic_tree'
}
export function getContributionScore(member: Member): number {
let score = 0
// PR contributions
score += member.prsMerged * 5
// Mutual aid
score += member.mutualAidTransactions * 3
// Proposals created
score += member.proposalsCreated * 10
// Events organized
score += member.eventsOrganized * 15
return score
}
Capability Unlocks
Rewards can unlock new features:
export interface CapabilityUnlock {
capability: 'create_proposal' | 'organize_event' | 'moderate' | 'steward'
requirements: {
badges?: string[]
contributionScore?: number
paths?: string[]
}
}
export function checkCapability(
member: Member,
capability: string
): boolean {
const unlock = CAPABILITY_UNLOCKS[capability]
if (!unlock) return false
// Check badges
if (unlock.requirements.badges) {
const hasBadges = unlock.requirements.badges.every(badgeId =>
member.badges.some(b => b.id === badgeId)
)
if (!hasBadges) return false
}
// Check contribution score
if (unlock.requirements.contributionScore) {
const score = getContributionScore(member)
if (score < unlock.requirements.contributionScore) return false
}
// Check paths
if (unlock.requirements.paths) {
const hasPath = unlock.requirements.paths.some(path =>
member.archetypes.includes(path)
)
if (!hasPath) return false
}
return true
}
Documentation Updates
After implementing a reward, update:
- Module spec:
docs/modules/rewards.md- Add reward type to list - Data models:
packages/types/src/rewards.ts- Export new interfaces - Fixtures:
packages/fixtures/src/badges.ts- Add example data - STATUS:
docs/STATUS_v2.md- Bump progress marker
Common Patterns
Auto-Award on Activity
// In governance handler after PR merge
export async function handlePRMerge(prId: string, memberId: string) {
// ... merge logic ...
// Check for badge eligibility
const member = await memberRepo.findById(memberId)
const prCount = await getPRCount(memberId)
if (prCount === 1) {
await awardBadge({
badgeId: 'badge-first-pr',
memberId,
reason: `PR #${prId} merged`,
}, badgeRepo)
}
}
Display Badge Progress
export function BadgeProgress({ badge, member }: Props) {
const progress = calculateProgress(badge, member)
return (
<div>
<BadgeCard badge={badge} locked={progress < 100} />
<div className="mt-2">
<div className="h-2 bg-gray-200 rounded">
<div
className="h-full bg-blue-500 rounded"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-gray-600 mt-1">
{progress}% complete
</p>
</div>
</div>
)
}
Validation Checklist
Before submitting PR:
- Entity model includes validation logic
- Repository interface defined with in-memory implementation
- Zod schemas created with proper constraints
- API handler validates input and handles errors
- UI component handles all states (loading, empty, error, success)
- Unit tests cover happy path and error cases
- Fixture data added for testing
- NDJSON transaction logging implemented
- Documentation updated (module spec, data models, STATUS)
-
./scripts/validate.shpasses with:LINT=OKVALIDATORS=GREENSMOKE=OK
References
For detailed patterns and examples, see:
- Reward Builder Guide:
docs/dev/reward-module-guide.md- Comprehensive templates and workflows - Data Models:
packages/types/src/rewards.ts- Complete type definitions - Social Economy: Knowledge base document on gamification and progression systems
Score
Total Score
Based on repository quality metrics
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
3ヶ月以内に更新
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
Reviews
Reviews coming soon
