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
| Query | Args | Returns | Guards |
|---|---|---|---|
rewards | -- | [Reward!]! | feature:rewards + rewards:read |
reward | id: UUID! | Reward | feature:rewards + rewards:read |
rewardsreturns all rewards for the active account, ordered bycreated_at ASC.rewardreturns a single reward by ID, filtered by account ownership.
GraphQL Mutations
| Mutation | Args | Returns | Guards |
|---|---|---|---|
createReward | input: CreateRewardInput! | Reward! | feature:rewards + rewards:create |
updateReward | input: UpdateRewardInput! | Reward! | feature:rewards + rewards:edit |
deleteReward | id: 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
| Permission | Description |
|---|---|
rewards:read | List and view rewards |
rewards:create | Create new rewards |
rewards:edit | Update existing rewards |
rewards:delete | Delete rewards |
Default-role assignment (ROLE_OWNER / ROLE_ADMIN / ROLE_MODERATOR / ROLE_VIEWER in crates/lo-auth/src/rbac.rs):
| Permission | Owner | Administrator | Moderator | Viewer |
|---|---|---|---|---|
rewards:read | x | x | x | |
rewards:create | x | x | ||
rewards:edit | x | x | ||
rewards:delete | x | x |
Viewer holds no rewards permission at all — it cannot see the rewards page.
Database
Table: channel_rewards
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Reward ID |
account_id | UUID (FK) | Owning account |
platform | TEXT | Platform identifier (e.g., twitch) |
platform_reward_id | TEXT | External platform reward ID (for sync) |
title | TEXT | Reward display title |
cost | INT | Point cost (nullable) |
enabled | BOOLEAN | Whether the reward is active |
action | JSONB | Configurable reward action payload |
created_at | TIMESTAMPTZ | Creation timestamp |
updated_at | TIMESTAMPTZ | Last update timestamp |
DB Functions
| Function | Description |
|---|---|
list_rewards | List all rewards for an account, ordered by created_at ASC |
get_reward | Get a single reward by ID |
create_reward | Insert a new reward |
update_reward | Partial update using COALESCE for optional fields |
delete_reward | Delete by ID, returns whether a row was deleted |
Key Files
| File | Purpose |
|---|---|
apps/api/src/graphql/rewards.rs | GraphQL queries, mutations, input/output types |
apps/api/src/db/rewards.rs | Database CRUD operations |
crates/lo-auth/src/rbac.rs | Permission constants (rewards:read/create/edit/delete) and default-role membership |
apps/web/src/app/(main)/(app)/dashboard/rewards/layout.tsx | FeatureRouteGate feature="feature:rewards" on the whole segment |
apps/web/src/app/(main)/(app)/dashboard/rewards/rewards-list.tsx | Dashboard rewards list |
apps/web/src/app/api/rewards/route.ts | Next.js proxy → GraphQL (list / create) |
apps/web/src/app/api/rewards/[id]/route.ts | Next.js proxy → GraphQL (update / delete) |