Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.sunschool.xyz/llms.txt

Use this file to discover all available pages before exploring further.

Points System Overview

Learners earn points (also called “tokens”) by completing lessons and quizzes. Points can be saved toward reward goals set by their parents, creating a motivating incentive system.

Earning Points

Points are awarded automatically when learners demonstrate learning progress.

Point Sources

The primary way to earn points is by answering quiz questions correctly.Base Rate: 1 point per correct answer
// From points-service.ts:3-8
export type PointsSource =
  | "QUIZ_CORRECT"
  | "LESSON_COMPLETE"
  | "ACHIEVEMENT"
  | "REDEMPTION"
  | "ADMIN_ADJUST";
Example: A 5-question quiz where you get 4 correct = 4 points
Additional points awarded for completing entire lessons.Purpose: Rewards sustained engagement, not just quiz performanceTypical Award: Bonus points on top of quiz performance
Some achievements come with point bonuses.Examples:
  • First lesson completed: Bonus points
  • Perfect quiz score: Extra points
  • Milestone achievements: Point rewards
Parents or admins can manually award or deduct points.Use Cases:
  • Rewarding offline learning
  • Correcting errors
  • Special bonuses for exceptional effort

Points Service Architecture

The points-service.ts manages all point transactions:
// From points-service.ts:10-16
export interface AwardPointsOptions {
  learnerId: number | string;
  amount: number; // positive integer
  sourceType: PointsSource;
  sourceId?: string;
  description: string;
}

Awarding Points

// From points-service.ts:18-60
class PointsService {
  async awardPoints(opts: AwardPointsOptions): Promise<{ newBalance: number }> {
    if (opts.amount <= 0) throw new Error("Amount must be positive");

    // Start transaction
    const client = await pool.connect();
    try {
      await client.query("BEGIN");

      // Insert ledger entry
      const insertLedgerSQL = `
        INSERT INTO points_ledger (learner_id, amount, source_type, source_id, description)
        VALUES ($1, $2, $3, $4, $5)
        RETURNING id`;
      await client.query(insertLedgerSQL, [
        opts.learnerId,
        opts.amount,
        opts.sourceType,
        opts.sourceId || null,
        opts.description,
      ]);

      // Upsert learner_points row and update balances
      const upsertSQL = `
        INSERT INTO learner_points (learner_id, current_balance, total_earned, total_redeemed)
        VALUES ($1, $2, $2, 0)
        ON CONFLICT (learner_id)
        DO UPDATE SET 
          current_balance = learner_points.current_balance + EXCLUDED.current_balance,
          total_earned = learner_points.total_earned + EXCLUDED.current_balance,
          last_updated = NOW()
        RETURNING current_balance`;
      const { rows } = await client.query(upsertSQL, [opts.learnerId, opts.amount]);

      await client.query("COMMIT");
      return { newBalance: rows[0].current_balance };
    } catch (err) {
      await client.query("ROLLBACK");
      throw err;
    } finally {
      client.release();
    }
  }
Transaction Safety: All point awards use database transactions to ensure consistency. If any part fails, the entire operation rolls back.

Getting Balance

// From points-service.ts:62-66
async getBalance(learnerId: number | string): Promise<number> {
  const sql = `SELECT current_balance FROM learner_points WHERE learner_id = $1`;
  const { rows } = await pool.query(sql, [learnerId]);
  return rows.length ? Number(rows[0].current_balance) : 0;
}

Points History

// From points-service.ts:68-72
async getHistory(learnerId: number | string, limit = 50) {
  const sql = `SELECT * FROM points_ledger WHERE learner_id = $1 ORDER BY created_at DESC LIMIT $2`;
  const { rows } = await pool.query(sql, [learnerId, limit]);
  return rows;
}

Browsing Rewards Catalog

Parents create rewards that learners can work toward.

Viewing Available Rewards

// From rewards-service.ts:236-265
export async function getRewardsForLearner(learnerId: number) {
  // Find parent of learner
  const { rows: userRows } = await pool.query(
    `SELECT parent_id FROM users WHERE id=$1`,
    [learnerId]
  );
  if (!userRows[0]?.parent_id) return [];

  const parentId = userRows[0].parent_id;

  const { rows } = await pool.query(
    `SELECT r.*,
       COALESCE(gs.saved_points, 0) AS saved_points,
       gs.id AS savings_id
     FROM rewards r
     LEFT JOIN reward_goal_savings gs
       ON gs.reward_id = r.id AND gs.learner_id = $1
     WHERE r.parent_id = $2 AND r.is_active = TRUE
     ORDER BY r.token_cost ASC, r.title ASC`,
    [learnerId, parentId]
  );

  return rows.map(row => ({
    ...toReward(row),
    savedPoints: row.saved_points ?? 0,
    savingsId: row.savings_id ?? null,
  }));
}

Reward Structure

// From learner-goals-page.tsx:13-24
interface RewardGoal {
  id: string;
  title: string;
  description: string | null;
  tokenCost: number;
  category: string;
  isActive: boolean;
  imageEmoji: string;
  color: string;
  savedPoints: number;
  hasPendingRedemption?: boolean;
}

Goals Page Display

// From learner-goals-page.tsx:239-265
<SafeAreaView style={styles.container}>
  {/* Header */}
  <View style={styles.header}>
    <Gift size={20} color={theme.colors.primary} />
    <Text style={styles.headerTitle}>My Goals</Text>
    <View style={styles.balanceBadge}>
      <Text style={styles.balanceText}>⭐ {balance} pts</Text>
    </View>
  </View>

  {/* Tabs */}
  <View style={styles.tabs}>
    {(['goals', 'history'] as const).map(t => (
      <TouchableOpacity key={t} style={styles.tab} onPress={() => setTab(t)}>
        <Text style={styles.tabText}>
          {t === 'goals' ? '🎯 Goals' : '📜 History'}
        </Text>
      </TouchableOpacity>
    ))}
  </View>
Shows all active reward goals with:
  • Emoji icon and title
  • Progress bar (saved / total needed)
  • Percentage complete
  • “Save Points” or “Cash Out!” buttons

Point Delegation (Saving Toward Goals)

Learners can move points from their general balance to specific reward goals.

Delegation Modal

// From learner-goals-page.tsx:123-197
const SavePointsModal: React.FC<{
  goal: RewardGoal | null;
  balance: number;
  learnerId: number;
  visible: boolean;
  onClose: () => void;
}> = ({ goal, balance, learnerId, visible, onClose }) => {
  const [points, setPoints] = useState(0);

  const saveMutation = useMutation({
    mutationFn: (pts: number) =>
      apiRequest('POST', `/api/rewards/${goal!.id}/save?learnerId=${learnerId}`, { points: pts })
        .then(r => r.data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['/api/rewards'] });
      queryClient.invalidateQueries({ queryKey: ['/api/points/balance'] });
      queryClient.invalidateQueries({ queryKey: ['/api/rewards-summary'] });
      onClose();
    },
  });

Preset Amounts

// From learner-goals-page.tsx:142-166
const saved2 = goal.savedPoints ?? 0;
const remaining = Math.max(0, goal.tokenCost - saved2);
const maxSave = Math.min(balance, remaining);
const presets = [1, 5, 10, maxSave].filter((v, i, a) => v > 0 && a.indexOf(v) === i);

// Preset buttons
<View style={styles.presetRow}>
  {presets.map(p => (
    <TouchableOpacity key={p}
      style={[styles.presetBtn, points === p && { backgroundColor: goal.color }]}
      onPress={() => setPoints(p)}>
      <Text style={styles.presetBtnText}>{p}</Text>
    </TouchableOpacity>
  ))}
</View>

// Custom amount with stepper
<View style={styles.customRow}>
  <TouchableOpacity onPress={() => setPoints(Math.max(0, points - 1))}>
    <Text>−</Text>
  </TouchableOpacity>
  <Text style={styles.pointsDisplay}>{points}</Text>
  <TouchableOpacity onPress={() => setPoints(Math.min(maxSave, points + 1))}>
    <Text>+</Text>
  </TouchableOpacity>
</View>

Backend Delegation

// From rewards-service.ts:282-332
export async function delegatePointsToGoal(
  learnerId: number,
  rewardId: string,
  points: number
) {
  if (points <= 0) throw new Error('Points must be positive');

  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    // Check balance
    const { rows: balRows } = await client.query(
      `SELECT current_balance FROM learner_points WHERE learner_id=$1`,
      [learnerId]
    );
    const balance = balRows[0]?.current_balance ?? 0;
    if (balance < points) throw new Error('Insufficient balance');

    // Deduct from learner_points
    await client.query(
      `UPDATE learner_points
       SET current_balance = current_balance - $1, last_updated = NOW()
       WHERE learner_id = $2`,
      [points, learnerId]
    );

    // Record ledger entry
    await client.query(
      `INSERT INTO points_ledger (learner_id, amount, source_type, source_id, description)
       VALUES ($1, $2, 'GOAL_DELEGATION', $3, 'Points saved toward reward goal')`,
      [learnerId, -points, rewardId]
    );

    // Upsert goal savings
    await client.query(
      `INSERT INTO reward_goal_savings (learner_id, reward_id, saved_points)
       VALUES ($1, $2, $3)
       ON CONFLICT (learner_id, reward_id)
       DO UPDATE SET saved_points = reward_goal_savings.saved_points + $3, updated_at = NOW()`,
      [learnerId, rewardId, points]
    );

    await client.query('COMMIT');
  }
Important: Delegated points are moved OUT of the general balance into goal-specific savings. They cannot be used for other goals unless the goal is deleted (which refunds points).

Redeeming Rewards

When a learner has saved enough points for a goal, they can request redemption.

Redemption Request

// From learner-goals-page.tsx:99-104
{isComplete && !goal.hasPendingRedemption && (
  <TouchableOpacity
    style={[styles.actionBtn, { backgroundColor: goal.color }]}
    onPress={() => onRedeem(goal.id)}>
    <Text style={styles.actionBtnText}>🎉 Cash Out!</Text>
  </TouchableOpacity>
)}

Backend Process

// From rewards-service.ts:338-371
export async function requestRedemption(learnerId: number, rewardId: string) {
  // Verify savings ≥ reward cost
  const { rows: rewardRows } = await pool.query(
    `SELECT token_cost FROM rewards WHERE id=$1 AND is_active=TRUE`,
    [rewardId]
  );
  if (!rewardRows[0]) throw new Error('Reward not found');
  const tokenCost = rewardRows[0].token_cost;

  const { rows: savingsRows } = await pool.query(
    `SELECT saved_points FROM reward_goal_savings WHERE learner_id=$1 AND reward_id=$2`,
    [learnerId, rewardId]
  );
  const savedPoints = savingsRows[0]?.saved_points ?? 0;
  if (savedPoints < tokenCost) {
    throw new Error(`Need ${tokenCost} saved points, have ${savedPoints}`);
  }

  // Check for existing pending request
  const { rows: existingRows } = await pool.query(
    `SELECT id FROM reward_redemptions
     WHERE learner_id=$1 AND reward_id=$2 AND status='PENDING'`,
    [learnerId, rewardId]
  );
  if (existingRows[0]) throw new Error('Redemption already pending');

  const { rows } = await pool.query(
    `INSERT INTO reward_redemptions (learner_id, reward_id, tokens_spent, status)
     VALUES ($1, $2, $3, 'PENDING')
     RETURNING *`,
    [learnerId, rewardId, tokenCost]
  );
  return toRedemption(rows[0]);
}

Redemption Statuses

1

PENDING

Initial State: Request submitted, awaiting parent reviewLearner sees: ”⏳ Waiting for parent approval…”
2

APPROVED

Parent Approved: Reward granted
  • Saved points reset to 0
  • Learner can earn the same reward again
  • Parent notes may be included
3

REJECTED

Parent Denied: Request declined
  • Saved points remain in goal
  • Learner can try again later
  • Parent can explain reason in notes

Redemption History

// From learner-goals-page.tsx:292-327
{tab === 'history' && (
  <>
    {redemptions.length === 0 ? (
      <View style={styles.empty}>
        <Text>📭</Text>
        <Text style={styles.emptyTitle}>No history yet</Text>
        <Text style={styles.emptyDesc}>
          Cash out your first reward to see it here!
        </Text>
      </View>
    ) : redemptions.map(r => (
      <View key={r.id} style={styles.historyCard}>
        <View style={styles.cardHeader}>
          <Text>{r.rewardEmoji}</Text>
          <View>
            <Text style={styles.cardTitle}>{r.rewardTitle}</Text>
            <Text style={styles.cardDesc}>
              {r.tokensSpent} pts · {new Date(r.requestedAt).toLocaleDateString()}
            </Text>
          </View>
          <View style={styles.statusBadge}>
            <Text>{r.status === 'APPROVED' ? '✓ Approved' : 
                      r.status === 'REJECTED' ? '✗ Rejected' : 
                      '⏳ Pending'}</Text>
          </View>
        </View>
        {r.parentNotes && (
          <Text style={styles.cardDesc}>
            Parent: {r.parentNotes}
          </Text>
        )}
      </View>
    ))}
  </>
)}

Next Steps

Learning Goals

Understand how parents set up reward goals

Achievements

See how achievements can award bonus points