11 KiB
ADR-049: Gamification and Achievement System
Date: 2026-01-11
Status: Accepted
Implemented: 2026-01-11
Context
The application implements a gamification system to encourage user engagement through achievements and points. Users earn achievements for completing specific actions within the platform, and these achievements contribute to a points-based leaderboard.
Key requirements:
- User Engagement: Reward users for meaningful actions (uploads, recipes, sharing).
- Progress Tracking: Show users their accomplishments and progress.
- Social Competition: Leaderboard to compare users by points.
- Idempotent Awards: Achievements should only be awarded once per user.
- Transactional Safety: Achievement awards must be atomic with the triggering action.
Decision
We will implement a database-driven gamification system with:
- Database Functions: Core logic in PostgreSQL for atomicity and idempotency.
- Database Triggers: Automatic achievement awards on specific events.
- Application-Level Awards: Explicit calls from service layer when triggers aren't suitable.
- Points Aggregation: Stored in user profile for efficient leaderboard queries.
Design Principles
- Single Award: Each achievement can only be earned once per user (enforced by unique constraint).
- Atomic Operations: Achievement awards happen within the same transaction as the triggering action.
- Silent Failure: If an achievement doesn't exist, the award function returns silently (no error).
- Points Sync: Points are updated on the profile immediately when an achievement is awarded.
Implementation Details
Database Schema
-- Achievements master table
CREATE TABLE public.achievements (
achievement_id BIGSERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT NOT NULL,
icon TEXT NOT NULL,
points_value INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User achievements (junction table)
CREATE TABLE public.user_achievements (
user_id UUID REFERENCES public.users(user_id) ON DELETE CASCADE,
achievement_id BIGINT REFERENCES public.achievements(achievement_id) ON DELETE CASCADE,
achieved_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, achievement_id)
);
-- Points stored on profile for efficient leaderboard
ALTER TABLE public.profiles ADD COLUMN points INTEGER DEFAULT 0;
Award Achievement Function
Located in sql/Initial_triggers_and_functions.sql:
CREATE OR REPLACE FUNCTION public.award_achievement(p_user_id UUID, p_achievement_name TEXT)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_achievement_id BIGINT;
v_points_value INTEGER;
BEGIN
-- Find the achievement by name to get its ID and point value.
SELECT achievement_id, points_value INTO v_achievement_id, v_points_value
FROM public.achievements WHERE name = p_achievement_name;
-- If the achievement doesn't exist, do nothing.
IF v_achievement_id IS NULL THEN
RETURN;
END IF;
-- Insert the achievement for the user.
-- ON CONFLICT DO NOTHING ensures idempotency.
INSERT INTO public.user_achievements (user_id, achievement_id)
VALUES (p_user_id, v_achievement_id)
ON CONFLICT (user_id, achievement_id) DO NOTHING;
-- If the insert was successful (user didn't have it), update their points.
IF FOUND THEN
UPDATE public.profiles SET points = points + v_points_value WHERE user_id = p_user_id;
END IF;
END;
$$;
Current Achievements
| Name | Description | Icon | Points |
|---|---|---|---|
| Welcome Aboard | Join the community by creating your account. | user-check | 5 |
| First Recipe | Create your very first recipe. | chef-hat | 10 |
| Recipe Sharer | Share a recipe with another user for the first time. | share-2 | 15 |
| List Sharer | Share a shopping list with another user for the first time. | list | 20 |
| First Favorite | Mark a recipe as one of your favorites. | heart | 5 |
| First Fork | Make a personal copy of a public recipe. | git-fork | 10 |
| First Budget Created | Create your first budget to track spending. | piggy-bank | 15 |
| First-Upload | Upload your first flyer. | upload-cloud | 25 |
Achievement Triggers
User Registration (Database Trigger)
Awards "Welcome Aboard" when a new user is created:
-- In handle_new_user() function
PERFORM public.award_achievement(new.user_id, 'Welcome Aboard');
Flyer Upload (Database Trigger + Application Code)
Awards "First-Upload" when a flyer is inserted with an uploaded_by value:
-- In log_new_flyer() trigger function
IF NEW.uploaded_by IS NOT NULL THEN
PERFORM public.award_achievement(NEW.uploaded_by, 'First-Upload');
END IF;
Additionally, the FlyerPersistenceService.saveFlyer() method explicitly awards the achievement within the transaction:
// In src/services/flyerPersistenceService.server.ts
if (userId) {
const gamificationRepo = new GamificationRepository(client);
await gamificationRepo.awardAchievement(userId, 'First-Upload', logger);
}
Repository Layer
Located in src/services/db/gamification.db.ts:
export class GamificationRepository {
private db: Pick<Pool | PoolClient, 'query'>;
constructor(db: Pick<Pool | PoolClient, 'query'> = getPool()) {
this.db = db;
}
async getUserAchievements(
userId: string,
logger: Logger,
): Promise<(UserAchievement & Achievement)[]> {
const query = `
SELECT ua.user_id, ua.achievement_id, ua.achieved_at,
a.name, a.description, a.icon, a.points_value, a.created_at
FROM public.user_achievements ua
JOIN public.achievements a ON ua.achievement_id = a.achievement_id
WHERE ua.user_id = $1
ORDER BY ua.achieved_at DESC;
`;
const res = await this.db.query(query, [userId]);
return res.rows;
}
async awardAchievement(userId: string, achievementName: string, logger: Logger): Promise<void> {
await this.db.query('SELECT public.award_achievement($1, $2)', [userId, achievementName]);
}
async getLeaderboard(limit: number, logger: Logger): Promise<LeaderboardUser[]> {
const query = `
SELECT user_id, full_name, avatar_url, points,
RANK() OVER (ORDER BY points DESC) as rank
FROM public.profiles
ORDER BY points DESC, full_name ASC
LIMIT $1;
`;
const res = await this.db.query(query, [limit]);
return res.rows;
}
}
API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/achievements |
List all available achievements |
| GET | /api/achievements/me |
Get current user's achievements |
| GET | /api/achievements/leaderboard |
Get top users by points |
Testing Considerations
Critical Testing Requirements
When testing gamification features, be aware of the following:
-
Database Seed Data: Achievement definitions must exist in the database before tests run. The
award_achievement()function silently returns if the achievement name doesn't exist. -
Transactional Context: When awarding achievements from within a transaction:
- The achievement is visible within the transaction immediately
- External queries won't see the achievement until the transaction commits
- Tests should wait for job completion before asserting achievement state
-
Vitest Global Setup Context: The integration test global setup runs in a separate Node.js context. Achievement verification must use direct database queries, not mocked services.
-
Achievement Idempotency: Calling
award_achievement()multiple times for the same user/achievement combination is safe and expected. Only the first call actually inserts.
Example Integration Test Pattern
it('should award the "First Upload" achievement after flyer processing', async () => {
// 1. Create user (awards "Welcome Aboard" via database trigger)
const { user: testUser, token } = await createAndLoginUser({...});
// 2. Upload flyer (triggers async job)
const uploadResponse = await request
.post('/api/flyers/upload')
.set('Authorization', `Bearer ${token}`)
.attach('flyerFile', testImagePath);
expect(uploadResponse.status).toBe(202);
// 3. Wait for job to complete
await poll(async () => {
const status = await request.get(`/api/flyers/job/${jobId}/status`);
return status.body.data.status === 'completed';
}, { timeout: 15000 });
// 4. Wait for achievements to be visible (transaction committed)
await vi.waitUntil(async () => {
const achievements = await db.gamificationRepo.getUserAchievements(
testUser.user.user_id,
logger
);
return achievements.length >= 2; // Welcome Aboard + First-Upload
}, { timeout: 15000, interval: 500 });
// 5. Assert specific achievements
const userAchievements = await db.gamificationRepo.getUserAchievements(
testUser.user.user_id,
logger
);
expect(userAchievements.find(a => a.name === 'Welcome Aboard')).toBeDefined();
expect(userAchievements.find(a => a.name === 'First-Upload')).toBeDefined();
});
Common Test Pitfalls
-
Missing Seed Data: If tests fail with "achievement not found", ensure the test database has the achievements table populated.
-
Race Conditions: Achievement awards in async jobs may not be visible immediately. Always poll or use
vi.waitUntil(). -
Wrong User ID: Verify the user ID passed to
awardAchievement()matches the user created in the test. -
Transaction Isolation: When querying within a test, use the same database connection if checking mid-transaction state.
Consequences
Positive
- Engagement: Users have clear goals and rewards for platform activity.
- Scalability: Points stored on profile enable O(1) leaderboard sorting.
- Reliability: Database-level idempotency prevents duplicate awards.
- Flexibility: New achievements can be added via SQL without code changes.
Negative
- Complexity: Multiple award paths (triggers + application code) require careful coordination.
- Testing: Async nature of some awards complicates integration testing.
- Coupling: Achievement names are strings; typos fail silently.
Mitigation
- Use constants for achievement names in application code.
- Document all award trigger points clearly.
- Test each achievement path independently.
Key Files
sql/initial_data.sql- Achievement definitions (seed data)sql/Initial_triggers_and_functions.sql-award_achievement()function and triggerssrc/services/db/gamification.db.ts- Repository layersrc/routes/achievements.routes.ts- API endpointssrc/services/flyerPersistenceService.server.ts- First-Upload award (application code)