Appearance
Touli Challenge Flow: Join → Complete Tasks → Complete Challenge (As-Built)
Purpose
Documents the actual backend implementation of the challenge lifecycle, from a user joining a challenge to being awarded points for completing it. This reflects the current code (apps/server/src/services/challenge.service.ts, apps/server/src/services/points.service.ts, and their DAOs/models/routes) — it is intentionally narrower and more concrete than the aspirational touli-points-dld.md (which describes a future config-snapshot/ledger design not yet built).
Entities involved
| Table | Purpose | Model |
|---|---|---|
challenges | Challenge definition (title, category, difficulty, estimatedTime in minutes, ≥1 task required) | Challenge |
challenge_tasks | Checklist items belonging to a challenge (title, optional description, verificationMode, pointsValue, sortOrder, optional schedule fields) | ChallengeTask |
user_challenges | One row per attempt (join). Repeatable — no unique constraint on (userId, challengeId) | UserChallenge |
user_task_progress | Current completion state of each task for a given attempt | UserTaskProgress |
deed_submissions | Append-only log of task photo evidence (no points) | DeedSubmission |
challenge_submissions | Append-only log of challenge time-entry submissions (points, bonuses, decay) | ChallengeSubmission |
points_wallets | Current point balance per user | PointsWallet |
wallet_transactions | Ledger of balance changes, linked to a challenge_submissions row | WalletTransaction |
Endpoints, in flow order
| Step | Method & path | Controller / Service | Effect |
|---|---|---|---|
| 1. Join | POST /challenges/:id/join | ChallengeController.joinChallenge → ChallengeService.joinChallenge | Creates a user_challenges row (state=accepted) + one user_task_progress row per task (state=accepted) |
| 2. Complete a task | POST /challenges/:id/tasks/:taskId/complete | ChallengeController.completeTask → ChallengeService.completeTask | Logs a deed_submissions row (photo evidence), marks that task's user_task_progress row completed, recalculates progressPercent |
| 3. Complete the challenge | POST /challenges/:id/complete | ChallengeController.completeChallenge → PointsService.submitHours | Validates time, computes bonuses/decay, creates a challenge_submissions row, credits the wallet, marks user_challenges.state=completed |
| — Abandon (alt.) | PATCH /challenges/:id/state ({ "state": "abandoned" }) | ChallengeController.updateUserChallengeState → ChallengeService.updateUserChallengeState | Marks the open attempt abandoned — the only value this endpoint accepts |
Read endpoints (profile & wallet)
These endpoints aggregate existing rows; they do not mutate state.
| Purpose | Method & path | Controller / Service | Source data |
|---|---|---|---|
| Points balance | GET /points/wallet | PointsController.getMyWallet → PointsService.getMyWallet | points_wallets.balance |
| Submission history | GET /points/submissions | PointsController.getMySubmissions → PointsService.getMySubmissions | challenge_submissions (paginated) |
| Wallet ledger | GET /points/wallet/transactions | PointsController.getMyWalletTransactions → PointsService.getMyWalletTransactions | wallet_transactions (paginated) |
| Profile activity stats | GET /profile/stats | ProfileController.getUserStats → ProfileService.getUserStats | See below |
GET /profile/stats response:
json
{
"totalPoints": 3093,
"deedsDone": 39,
"dayStreak": 8
}| Field | Definition |
|---|---|
totalPoints | Current wallet balance (points_wallets.balance; 0 if no wallet row yet) |
deedsDone | Count of challenge_submissions with status = approved for the user |
dayStreak | Consecutive UTC calendar days with ≥1 approved submission. Counts through today when the user completed today; otherwise through yesterday so an active streak still displays before today's completion |
All routes require Authenticate(). Step 3 additionally validates the body against completeChallengeDtoSchema.
Step-by-step
1. Join a challenge
ChallengeService.joinChallenge(userId, challengeId):
- Looks up the user's most recent
user_challengesrow for this challenge (UserChallengeDao.getUserChallengeById, orderedcreatedAt DESC). - Blocks with
409 ALREADY_EXISTSonly if that most recent row is stillaccepted/in_progress— challenges are repeatable, so a priorcompleted,abandoned, orlockedattempt does not block rejoining. - Requires the challenge
status = active(404 NOT_FOUNDotherwise). - Creates a new
user_challengesrow (state=accepted,progressPercent=0,acceptedAt=now) — this is what starts the server-side clock used for time validation in step 3. - Calls
initTaskProgressto create oneuser_task_progressrow per task (state=accepted). Every challenge has at least one task (enforced bycreateChallengeDtoSchema'stasks: { minItems: 1 }), so this always runs.
2. Complete tasks (repeatable, 0+ times per task)
ChallengeService.completeTask(userId, challengeId, taskId, { photoUrl, description? }):
- Re-fetches the user's current attempt and re-applies the same open-attempt gate as step 3 (see below) —
404 NOT_FOUND,409 CHALLENGE_ALREADY_COMPLETED,409 CHALLENGE_LOCKED, or409 CHALLENGE_NOT_ACTIVE(abandoned). - Confirms
taskIdbelongs tochallengeId(404 NOT_FOUNDotherwise). - Confirms the task isn't already completed on this attempt (
409 TASK_ALREADY_COMPLETEDotherwise). - Inserts a
deed_submissionsrow:userId,userChallengeId,challengeTaskId,photoUrl,description,verificationMethod: 'manual',status: approved. No points or minutes are involved here — this is purely an evidence log (forward-compatible with future AI photo verification, per theverificationMethodfield). - Marks the task's
user_task_progressrowcompleted. UserChallengeDao.recalculateProgressrecomputesprogressPercent(done / total * 100) and bumpsuser_challenges.statefromaccepted→in_progressthe first time any task is done.
There is no cap on how many times step 2 can be called per attempt beyond "once per task" — a user works through the checklist at their own pace before moving to step 3.
3. Complete the challenge (awards points)
PointsService.submitHours(userId, challengeId, { minutes }), called via ChallengeController.completeChallenge:
Preconditions (in order):
- Attempt must exist →
404 NOT_FOUND. - Attempt must not already be
completed→409 CHALLENGE_ALREADY_COMPLETED. - Attempt must not be
locked→409 CHALLENGE_LOCKED. - Attempt must not be
abandoned→409 CHALLENGE_NOT_ACTIVE. - At least one task must be completed (
progressPercent > 0) →409 NO_TASKS_COMPLETEDotherwise. (This is the gate added specifically so a user can't skip straight from join to claiming points with zero evidence submitted.) - Challenge itself must still be
status=active→409 CHALLENGE_NOT_ACTIVE.
Caps (checked before time validation, all cross-challenge, per user):
- Max
MAX_DAILY_SUBMISSIONS(3) challenge-completion submissions per UTC calendar day →429 DAILY_SUBMISSION_LIMIT. - Max
MAX_DAILY_CREDITABLE_MINUTES(720, 12h) credited per UTC day →429 DAILY_HOURS_LIMIT. - Max
MAX_WEEKLY_CREDITABLE_MINUTES(1500, 25h) credited per UTC calendar week (Monday 00:00 UTC start) →429 WEEKLY_HOURS_LIMIT. minutesitself is bounded 1–MAX_MINUTES_PER_SUBMISSION(480, 8h) at the DTO level.
These are hard blocks — hitting one rolls back the transaction and nothing is written (not treated as a fraud signal, just "try again within the window").
Time validation (challenge completion only):
timeValid = minutes <= challenge.estimatedTimePer-task startedAt / completedAt are logged on Complete Task for auditing only. User-entered minutes are validated once here, against the challenge estimate.
- If invalid (
minutes > estimatedTime): expected business outcome. Achallenge_submissionsrow is written withstatus: rejected,pointsAwarded: 0. The transaction commits. AfterMAX_FAILED_SUBMISSION_ATTEMPTS(3) rejections,user_challenges.stateis set tolockedfor this attempt. Response:200 { accepted: false, reason: "EXCEEDS_ESTIMATED_TIME", estimatedTime, attemptsRemaining, locked }. - If valid: proceeds to point calculation below.
Point calculation (success path):
minutesCredited = min(minutes, remainingDailyMinutes, remainingWeeklyMinutes)
completionBonus = progressPercent === 100 ? +15% : 0 // ALL tasks done
streakBonus = +5% per consecutive prior month with an // capped +30%,
approved submission, starting from month 3 // accrues from month 3
diversityBonus = +8% if this user's approved submissions this
calendar month (incl. this one) span ≥3
distinct challenge_categories // categories = "pillars"
totalMultiplier = min(1 + completionBonus + streakBonus + diversityBonus, 2.5) // hard ceiling
repeatFactor = this exact challengeId has ≥3 approved completions
(incl. this one) in the current UTC week ? 0.5 : 1
pointsAwarded = round(55 * (minutesCredited / 60) * totalMultiplier * repeatFactor)Then, within the same transaction:
- Insert a
challenge_submissionsrow (status: approved, all the above values). - If
pointsAwarded > 0: insert awallet_transactionsrow (type: deed_reward, linked viasubmissionId→ thechallenge_submissionsrow) and incrementpoints_wallets.balance. - Set
user_challenges.state = completed— regardless of whether all tasks were checked off (only the completion bonus cares about that; the challenge itself can be completed as soon as ≥1 task is done and time validates). - Commit. Response:
201 { accepted: true, submission, pointsAwarded, minutesCredited, newBalance, bonuses, repeatFactor, ... }.
Alternative: abandon instead of completing
PATCH /challenges/:id/state with { "state": "abandoned" } sets the open attempt's state to abandoned (409 CONFLICT if it's already completed/abandoned). This is the only value this endpoint accepts — a challenge can no longer be marked completed through this route; that only ever happens via step 3.
State machine (user_challenges.state)
mermaid
stateDiagram-v2
[*] --> accepted: join
accepted --> in_progress: first task completed
in_progress --> in_progress: more tasks completed
accepted --> completed: submit minutes (time valid, ≥1 task done)
in_progress --> completed: submit minutes (time valid, ≥1 task done)
accepted --> locked: 3rd rejected time submission
in_progress --> locked: 3rd rejected time submission
accepted --> abandoned: PATCH state=abandoned
in_progress --> abandoned: PATCH state=abandoned
completed --> [*]
locked --> [*]
abandoned --> [*]
completed --> accepted: rejoin (new row)
locked --> accepted: rejoin (new row)
abandoned --> accepted: rejoin (new row)completed/locked/abandoned are terminal for that specific attempt row — rejoining (step 1) always creates a brand-new user_challenges row rather than resetting the old one, so a user's full history of attempts is preserved.
Sequence diagram
mermaid
sequenceDiagram
actor User
participant API
participant ChallengeSvc as ChallengeService
participant PointsSvc as PointsService
participant DB
User->>API: POST /challenges/:id/join
API->>ChallengeSvc: joinChallenge(userId, challengeId)
ChallengeSvc->>DB: insert user_challenges (accepted)
ChallengeSvc->>DB: insert user_task_progress rows (accepted)
ChallengeSvc-->>User: 201 attempt
loop for each task
User->>API: POST /challenges/:id/tasks/:taskId/complete {photoUrl}
API->>ChallengeSvc: completeTask(...)
ChallengeSvc->>DB: insert deed_submissions (evidence, no points)
ChallengeSvc->>DB: update user_task_progress -> completed
ChallengeSvc->>DB: recalc progressPercent, maybe -> in_progress
ChallengeSvc-->>User: 200 {progressPercent}
end
User->>API: POST /challenges/:id/complete {minutes}
API->>PointsSvc: submitHours(userId, challengeId, {minutes})
PointsSvc->>DB: check state, progressPercent>0, caps
alt time invalid
PointsSvc->>DB: insert challenge_submissions (rejected)
PointsSvc->>DB: maybe set user_challenges -> locked (3rd strike)
PointsSvc-->>User: 200 {accepted:false, EXCEEDS_ESTIMATED_TIME}
else time valid
PointsSvc->>DB: insert challenge_submissions (approved, pointsAwarded)
PointsSvc->>DB: insert wallet_transactions + increment balance
PointsSvc->>DB: update user_challenges -> completed
PointsSvc-->>User: 201 {accepted:true, pointsAwarded, newBalance, ...}
endKey design decisions worth calling out
- No AI verification (yet). Every "verification" in this flow is either a numeric time check (step 3) or a user-submitted photo with no automated content analysis (step 2).
verificationMethod: 'manual'ondeed_submissionsis the seam for adding that later. - Tasks vs. minutes are independent axes. Tasks gate whether the challenge can be completed (≥1 required) and contribute the completion bonus (all-or-nothing, +15%), but the actual points come entirely from the time entry in step 3 — a challenge with every task checked off but an invalid time entry still earns 0 points.
- Repeatable challenges, append-only history. Nothing is ever deleted or reset;
user_challengesaccumulates one row per attempt,deed_submissionsaccumulates one row per task-evidence submission,challenge_submissionsaccumulates one row per time-entry attempt (including rejected ones). - Points constants live in
apps/server/src/common/constants/points.constants.ts— see that file for the current numeric values of every threshold referenced above.