All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m54s
300 lines
11 KiB
Markdown
300 lines
11 KiB
Markdown
# 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)
|