Files
flyer-crawler.projectium.com/docs/adr/0049-gamification-and-achievement-system.md
Torben Sorensen 4e22213cd1
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m54s
all the new shiny things
2026-01-11 02:04:52 -08:00

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:

  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

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

  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

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)
  • ADR-002 - Transaction Management
  • ADR-034 - Repository Pattern
  • ADR-006 - Background Jobs (flyer processing)