Skip to main content

Rewards

Overview

The rewards module manages channel rewards (e.g., Twitch Channel Points) with full CRUD operations. Each reward is scoped to an account and platform, with an optional link to the platform's native reward ID. Rewards carry a configurable action JSONB field that defines what happens when the reward is redeemed.

The whole surface is gated on the feature:rewards feature flag in addition to the rewards:* permissions — every query and mutation chains FeatureGuard::new("feature:rewards") before the permission guard, and the dashboard route /dashboard/rewards is wrapped in a FeatureRouteGate for the same flag.

Architecture

Dashboard UI (/dashboard/rewards)
|
v
Next.js API Proxy (/api/rewards, /api/rewards/[id])
|
v
GraphQL (RewardQuery / RewardMutation)
|
v
db::rewards (PostgreSQL)

Rewards are stored in the channel_rewards table and can be synced with external platform reward systems via the platform_reward_id field. The action JSONB field allows flexible behavior configuration without schema changes.

There is no automatic sync worker: platform_reward_id is a value the caller supplies at creation and it cannot be changed afterwards (UpdateRewardInput carries no platformRewardId field).

API

GraphQL Queries

QueryArgsReturnsGuards
rewards--[Reward!]!feature:rewards + rewards:read
rewardid: UUID!Rewardfeature:rewards + rewards:read
  • rewards returns all rewards for the active account, ordered by created_at ASC.
  • reward returns a single reward by ID, filtered by account ownership.

GraphQL Mutations

MutationArgsReturnsGuards
createRewardinput: CreateRewardInput!Reward!feature:rewards + rewards:create
updateRewardinput: UpdateRewardInput!Reward!feature:rewards + rewards:edit
deleteRewardid: UUID!DeleteRewardResult!feature:rewards + rewards:delete

All mutations verify account ownership before proceeding. A reward owned by another account is reported as Reward not found rather than a distinct authorization error, so ownership is not leaked.

GraphQL Types

type Reward {
id: UUID!
accountId: UUID!
platform: String!
platformRewardId: String
title: String!
cost: Int
enabled: Boolean!
action: JSON!
createdAt: String!
updatedAt: String!
}

input CreateRewardInput {
platform: String!
platformRewardId: String
title: String!
cost: Int
action: JSON
}

input UpdateRewardInput {
id: UUID!
title: String
cost: Int
enabled: Boolean
action: JSON
}

type DeleteRewardResult {
success: Boolean!
}

REST Endpoints

Rewards have no REST resource — /v1/rewards does not exist and no rewards operation appears in apps/api/openapi.json. External scripts CRUD rewards through GraphQL (POST /v1/gql). This is a gap against the three-protocol rule, not a design choice.

WebSocket

There is no rewards channel. channel_gate_for in crates/lo-websocket/src/gate.rs has no rewards arm, so a rewards:{account_id} subscription resolves to ChannelGate::Unknown and is rejected. Reward redemptions arrive as platform events on the events:{account_id} channel (e.g. Twitch's channel.channel_points_custom_reward_redemption.add), not as reward-object changes.

Action JSONB

The action field is a flexible JSONB object that defines reward behavior. It defaults to {} if not provided during creation. The structure is application-defined and can vary per use case (e.g., trigger an overlay alert, play a sound, enable a chat mode).

Permissions

PermissionDescription
rewards:readList and view rewards
rewards:createCreate new rewards
rewards:editUpdate existing rewards
rewards:deleteDelete rewards

Default-role assignment (ROLE_OWNER / ROLE_ADMIN / ROLE_MODERATOR / ROLE_VIEWER in crates/lo-auth/src/rbac.rs):

PermissionOwnerAdministratorModeratorViewer
rewards:readxxx
rewards:createxx
rewards:editxx
rewards:deletexx

Viewer holds no rewards permission at all — it cannot see the rewards page.

Database

Table: channel_rewards

ColumnTypeDescription
idUUID (PK)Reward ID
account_idUUID (FK)Owning account
platformTEXTPlatform identifier (e.g., twitch)
platform_reward_idTEXTExternal platform reward ID (for sync)
titleTEXTReward display title
costINTPoint cost (nullable)
enabledBOOLEANWhether the reward is active
actionJSONBConfigurable reward action payload
created_atTIMESTAMPTZCreation timestamp
updated_atTIMESTAMPTZLast update timestamp

DB Functions

FunctionDescription
list_rewardsList all rewards for an account, ordered by created_at ASC
get_rewardGet a single reward by ID
create_rewardInsert a new reward
update_rewardPartial update using COALESCE for optional fields
delete_rewardDelete by ID, returns whether a row was deleted

Key Files

FilePurpose
apps/api/src/graphql/rewards.rsGraphQL queries, mutations, input/output types
apps/api/src/db/rewards.rsDatabase CRUD operations
crates/lo-auth/src/rbac.rsPermission constants (rewards:read/create/edit/delete) and default-role membership
apps/web/src/app/(main)/(app)/dashboard/rewards/layout.tsxFeatureRouteGate feature="feature:rewards" on the whole segment
apps/web/src/app/(main)/(app)/dashboard/rewards/rewards-list.tsxDashboard rewards list
apps/web/src/app/api/rewards/route.tsNext.js proxy → GraphQL (list / create)
apps/web/src/app/api/rewards/[id]/route.tsNext.js proxy → GraphQL (update / delete)