Skip to content

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

TablePurposeModel
challengesChallenge definition (title, category, difficulty, estimatedTime in minutes, ≥1 task required)Challenge
challenge_tasksChecklist items belonging to a challenge (title, optional description, verificationMode, pointsValue, sortOrder, optional schedule fields)ChallengeTask
user_challengesOne row per attempt (join). Repeatable — no unique constraint on (userId, challengeId)UserChallenge
user_task_progressCurrent completion state of each task for a given attemptUserTaskProgress
deed_submissionsAppend-only log of task photo evidence (no points)DeedSubmission
challenge_submissionsAppend-only log of challenge time-entry submissions (points, bonuses, decay)ChallengeSubmission
points_walletsCurrent point balance per userPointsWallet
wallet_transactionsLedger of balance changes, linked to a challenge_submissions rowWalletTransaction

Endpoints, in flow order

StepMethod & pathController / ServiceEffect
1. JoinPOST /challenges/:id/joinChallengeController.joinChallengeChallengeService.joinChallengeCreates a user_challenges row (state=accepted) + one user_task_progress row per task (state=accepted)
2. Complete a taskPOST /challenges/:id/tasks/:taskId/completeChallengeController.completeTaskChallengeService.completeTaskLogs a deed_submissions row (photo evidence), marks that task's user_task_progress row completed, recalculates progressPercent
3. Complete the challengePOST /challenges/:id/completeChallengeController.completeChallengePointsService.submitHoursValidates 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.updateUserChallengeStateChallengeService.updateUserChallengeStateMarks the open attempt abandoned — the only value this endpoint accepts

Read endpoints (profile & wallet)

These endpoints aggregate existing rows; they do not mutate state.

PurposeMethod & pathController / ServiceSource data
Points balanceGET /points/walletPointsController.getMyWalletPointsService.getMyWalletpoints_wallets.balance
Submission historyGET /points/submissionsPointsController.getMySubmissionsPointsService.getMySubmissionschallenge_submissions (paginated)
Wallet ledgerGET /points/wallet/transactionsPointsController.getMyWalletTransactionsPointsService.getMyWalletTransactionswallet_transactions (paginated)
Profile activity statsGET /profile/statsProfileController.getUserStatsProfileService.getUserStatsSee below

GET /profile/stats response:

json
{
  "totalPoints": 3093,
  "deedsDone": 39,
  "dayStreak": 8
}
FieldDefinition
totalPointsCurrent wallet balance (points_wallets.balance; 0 if no wallet row yet)
deedsDoneCount of challenge_submissions with status = approved for the user
dayStreakConsecutive 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):

  1. Looks up the user's most recent user_challenges row for this challenge (UserChallengeDao.getUserChallengeById, ordered createdAt DESC).
  2. Blocks with 409 ALREADY_EXISTS only if that most recent row is still accepted/in_progress — challenges are repeatable, so a prior completed, abandoned, or locked attempt does not block rejoining.
  3. Requires the challenge status = active (404 NOT_FOUND otherwise).
  4. Creates a new user_challenges row (state=accepted, progressPercent=0, acceptedAt=now) — this is what starts the server-side clock used for time validation in step 3.
  5. Calls initTaskProgress to create one user_task_progress row per task (state=accepted). Every challenge has at least one task (enforced by createChallengeDtoSchema's tasks: { minItems: 1 }), so this always runs.

2. Complete tasks (repeatable, 0+ times per task)

ChallengeService.completeTask(userId, challengeId, taskId, { photoUrl, description? }):

  1. 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, or 409 CHALLENGE_NOT_ACTIVE (abandoned).
  2. Confirms taskId belongs to challengeId (404 NOT_FOUND otherwise).
  3. Confirms the task isn't already completed on this attempt (409 TASK_ALREADY_COMPLETED otherwise).
  4. Inserts a deed_submissions row: 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 the verificationMethod field).
  5. Marks the task's user_task_progress row completed.
  6. UserChallengeDao.recalculateProgress recomputes progressPercent (done / total * 100) and bumps user_challenges.state from acceptedin_progress the 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):

  1. Attempt must exist → 404 NOT_FOUND.
  2. Attempt must not already be completed409 CHALLENGE_ALREADY_COMPLETED.
  3. Attempt must not be locked409 CHALLENGE_LOCKED.
  4. Attempt must not be abandoned409 CHALLENGE_NOT_ACTIVE.
  5. At least one task must be completed (progressPercent > 0) → 409 NO_TASKS_COMPLETED otherwise. (This is the gate added specifically so a user can't skip straight from join to claiming points with zero evidence submitted.)
  6. Challenge itself must still be status=active409 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.
  • minutes itself 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.estimatedTime

Per-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. A challenge_submissions row is written with status: rejected, pointsAwarded: 0. The transaction commits. After MAX_FAILED_SUBMISSION_ATTEMPTS (3) rejections, user_challenges.state is set to locked for 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:

  1. Insert a challenge_submissions row (status: approved, all the above values).
  2. If pointsAwarded > 0: insert a wallet_transactions row (type: deed_reward, linked via submissionId → the challenge_submissions row) and increment points_wallets.balance.
  3. 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).
  4. 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, ...}
    end

Key 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' on deed_submissions is 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_challenges accumulates one row per attempt, deed_submissions accumulates one row per task-evidence submission, challenge_submissions accumulates 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.