Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ service. It is intentionally short — execution happens in feature branches.

## Next

- Replace in-memory stores in `src/services/reward.service.ts` with Prisma
calls, removing the implicit in-test singletons.
- Replace in-memory stores in `src/services/reward.service.ts` with Prisma
calls, removing the implicit in-test singletons (completed in #15).
- Add structured request IDs and propagate them across logs and HTTP
responses.
- Add OpenTelemetry traces for outbound Stellar RPC and webhook delivery.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Migration: extend Transaction model and add RewardClaim for durable reward persistence
--
-- Adds moduleId, stellarTxHash, completedAt to transactions so the reward ledger can
-- be fully reconstructed from Postgres after a process restart.
-- Adds reward_claims table with a (userId, moduleId) unique constraint to provide
-- database-level double-claim prevention across replicas.

ALTER TABLE "transactions"
ADD COLUMN IF NOT EXISTS "moduleId" TEXT,
ADD COLUMN IF NOT EXISTS "stellarTxHash" TEXT,
ADD COLUMN IF NOT EXISTS "completedAt" TIMESTAMPTZ;

-- Rename the free-form comment values to match the service taxonomy:
-- old: reward, refund, transfer → new: module_reward, streak_bonus, referral_reward, withdrawal
-- Existing rows are preserved; only new rows will use the new taxonomy.
-- (No UPDATE applied here — existing data was mock/seed data only.)

CREATE TABLE IF NOT EXISTS "reward_claims" (
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
"userId" TEXT NOT NULL,
"moduleId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT "reward_claims_pkey" PRIMARY KEY ("id"),
CONSTRAINT "reward_claims_userId_moduleId_key" UNIQUE ("userId", "moduleId")
);
18 changes: 17 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,28 @@ model Transaction {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
amount Float
type String // reward, refund, transfer
type String // module_reward, streak_bonus, referral_reward, withdrawal
status String @default("pending") // pending, completed, failed
moduleId String? // present for module_reward and streak_bonus
stellarTxHash String? // populated after on-chain settlement
completedAt DateTime? // set when status transitions to completed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

/// Tracks which (user, module) pairs have already had a reward claimed.
/// The unique constraint is the database-level guard against double-claims
/// across replicas; application-level checks read this table first.
model RewardClaim {
id String @id @default(uuid())
userId String
moduleId String
createdAt DateTime @default(now())

@@unique([userId, moduleId])
@@map("reward_claims")
}

enum Role {
ADMIN
LEARNER
Expand Down
8 changes: 4 additions & 4 deletions src/controllers/reward.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class RewardController {
throw new UnauthorizedError('User ID not found')
}

const balance = this.rewardService.getBalance(userId)
const balance = await this.rewardService.getBalance(userId)

res.json({
success: true,
Expand Down Expand Up @@ -169,7 +169,7 @@ export class RewardController {
filters.offset = offset
}

const history = this.rewardService.getTransactionHistory(userId, filters)
const history = await this.rewardService.getTransactionHistory(userId, filters)

res.json({
success: true,
Expand Down Expand Up @@ -255,8 +255,8 @@ export class RewardController {
}

// Check if user has sufficient balance
if (!this.rewardService.hasSufficientBalance(userId, amount)) {
const balance = this.rewardService.getBalance(userId)
if (!(await this.rewardService.hasSufficientBalance(userId, amount))) {
const balance = await this.rewardService.getBalance(userId)
throw new BadRequestError(
`Insufficient balance. Available: ${balance.available} XLM, Requested: ${amount} XLM`,
)
Expand Down
Loading
Loading