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

```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)
