# 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: 1. **User Engagement**: Reward users for meaningful actions (uploads, recipes, sharing). 2. **Progress Tracking**: Show users their accomplishments and progress. 3. **Social Competition**: Leaderboard to compare users by points. 4. **Idempotent Awards**: Achievements should only be awarded once per user. 5. **Transactional Safety**: Achievement awards must be atomic with the triggering action. ## Decision We will implement a database-driven gamification system with: 1. **Database Functions**: Core logic in PostgreSQL for atomicity and idempotency. 2. **Database Triggers**: Automatic achievement awards on specific events. 3. **Application-Level Awards**: Explicit calls from service layer when triggers aren't suitable. 4. **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 ```sql -- 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`: ```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: ```sql -- 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: ```sql -- 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: ```typescript // 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`: ```typescript export class GamificationRepository { private db: Pick; constructor(db: Pick = 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 { await this.db.query('SELECT public.award_achievement($1, $2)', [userId, achievementName]); } async getLeaderboard(limit: number, logger: Logger): Promise { 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: 1. **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. 2. **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 3. **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. 4. **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 ```typescript 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 1. **Missing Seed Data**: If tests fail with "achievement not found", ensure the test database has the achievements table populated. 2. **Race Conditions**: Achievement awards in async jobs may not be visible immediately. Always poll or use `vi.waitUntil()`. 3. **Wrong User ID**: Verify the user ID passed to `awardAchievement()` matches the user created in the test. 4. **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 triggers - `src/services/db/gamification.db.ts` - Repository layer - `src/routes/achievements.routes.ts` - API endpoints - `src/services/flyerPersistenceService.server.ts` - First-Upload award (application code) ## Related ADRs - [ADR-002](./0002-standardized-transaction-management.md) - Transaction Management - [ADR-034](./0034-repository-pattern-standards.md) - Repository Pattern - [ADR-006](./0006-background-job-processing-and-task-queues.md) - Background Jobs (flyer processing)