Skip to content

Commit 090ebb8

Browse files
Merge pull request #37 from Jamesllllllllll/codex/vip-request-upgrade-flow
Allow upgrading existing requests between regular and VIP
2 parents 1681b81 + 3a9d179 commit 090ebb8

File tree

10 files changed

+865
-51
lines changed

10 files changed

+865
-51
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ All notable changes to this project will be documented in this file.
2323
- Broadcaster login now also requests the Twitch permissions needed for gifted-sub and cheer-based VIP token automation.
2424
- The app header and settings pages now surface Twitch reauthorization more clearly when a reconnect is required.
2525
- VIP token balances now support fractional values, including partial token grants and clearer balance handling when a viewer has less than one full VIP token remaining.
26+
- Existing requests can now be converted between regular and VIP from chat and from the playlist manager without creating duplicate playlist entries.
2627
- Local-development guidance now strongly separates production bot/broadcaster usage from local testing and explains the risks of cross-environment chat handling.
2728
- The moderation dashboard now supports faster Twitch username search with debouncing, in-chat prioritization, and clearer saved-state feedback for VIP tokens.
2829
- Search results now show newer song versions first, and public search includes a dedicated `!edit` copy command.
@@ -39,6 +40,7 @@ All notable changes to this project will be documented in this file.
3940
- Duplicate EventSub deliveries for cheers and gifted-sub automation no longer double-grant VIP tokens.
4041
- Twitch reply handling now distinguishes between accepted API requests and messages that Twitch actually sent to chat.
4142
- Bot/account status screens now show the real connected bot identity instead of only the configured bot name.
43+
- VIP request upgrade and downgrade replies now clearly state when a token was used or refunded, and dashboard-triggered request kind changes follow the same token logic and bot reply flow as chat commands.
4244
- Production deployment config regeneration now stays in sync after remote migrations.
4345

4446
## [0.1.0] - 2026-03-18

docs/testing-plan.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ These are the first automated checks worth investing in:
3636
- dashboard settings save flow shows success or warning messages
3737
- moderation page can grant or remove VIP tokens
3838
- playlist page reflects live updates and management actions
39+
- playlist page can convert a request between regular and VIP and keep token usage in sync
3940

4041
#### Manual smoke tests
4142

@@ -46,6 +47,7 @@ Keep these as manual checks, not core CI blockers:
4647
- real EventSub subscription creation
4748
- real chat reply delivery
4849
- real tunnel or public callback behavior
50+
- real `!vip` upgrade / downgrade behavior and dashboard playlist VIP toggles against a live channel
4951

5052
### What not to over-invest in
5153

src/lib/eventsub/chat-message.ts

Lines changed: 148 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,15 @@ export interface EventSubChatState {
6161
outcome: string;
6262
createdAt: number;
6363
}>;
64-
items: unknown[];
64+
items: Array<{
65+
id: string;
66+
songId: string | null;
67+
status: string;
68+
requestKind: string | null;
69+
requestedByTwitchUserId: string | null;
70+
requestedByLogin: string | null;
71+
requestedByDisplayName: string | null;
72+
}>;
6573
}
6674

6775
export interface VipTokenBalance {
@@ -176,6 +184,14 @@ export interface EventSubChatDependencies {
176184
kind: "regular" | "vip" | "all";
177185
}
178186
): Promise<PlaylistMutationResult>;
187+
changeRequestKind(
188+
env: AppEnv,
189+
input: {
190+
channelId: string;
191+
itemId: string;
192+
requestKind: "regular" | "vip";
193+
}
194+
): Promise<PlaylistMutationResult>;
179195
createRequestLog(
180196
env: AppEnv,
181197
input: Record<string, unknown>
@@ -225,6 +241,25 @@ function buildCandidateMatchesJson(results: SongSearchResult[]) {
225241
);
226242
}
227243

244+
function getRequesterMatchingActiveItem(input: {
245+
items: EventSubChatState["items"];
246+
requesterTwitchUserId: string;
247+
matchedSongId: string | null;
248+
}) {
249+
if (!input.matchedSongId) {
250+
return null;
251+
}
252+
253+
return (
254+
input.items.find(
255+
(item) =>
256+
item.songId === input.matchedSongId &&
257+
item.requestedByTwitchUserId === input.requesterTwitchUserId &&
258+
(item.status === "queued" || item.status === "current")
259+
) ?? null
260+
);
261+
}
262+
228263
function getRejectedSongMessage(input: {
229264
login: string;
230265
reason?: string;
@@ -604,28 +639,6 @@ export async function processEventSubChatMessage(input: {
604639
const effectiveActiveCount = isEditCommand
605640
? Math.max(0, existingActiveCount - existingActiveCount)
606641
: existingActiveCount;
607-
if (Number.isFinite(activeLimit)) {
608-
if (effectiveActiveCount >= activeLimit) {
609-
const message = `You already have ${activeLimit} active request${activeLimit === 1 ? "" : "s"} in the playlist.`;
610-
await deps.createRequestLog(env, {
611-
channelId: channel.id,
612-
twitchMessageId: event.messageId,
613-
twitchUserId: requesterIdentity.twitchUserId,
614-
requesterLogin: requesterIdentity.login,
615-
requesterDisplayName: requesterIdentity.displayName,
616-
rawMessage: event.rawMessage,
617-
normalizedQuery: parsed.query,
618-
outcome: "rejected",
619-
outcomeReason: "active_request_limit",
620-
});
621-
await deps.sendChatReply(env, {
622-
channelId: channel.id,
623-
broadcasterUserId: channel.twitchChannelId,
624-
message,
625-
});
626-
return { body: "Rejected", status: 202 };
627-
}
628-
}
629642

630643
const timeWindow = getRateLimitWindow(state.settings, requesterContext);
631644
if (timeWindow) {
@@ -857,8 +870,40 @@ export async function processEventSubChatMessage(input: {
857870
}
858871
}
859872

873+
const existingMatchingRequest = getRequesterMatchingActiveItem({
874+
items: state.items,
875+
requesterTwitchUserId: requesterIdentity.twitchUserId,
876+
matchedSongId: firstMatch?.id ?? null,
877+
});
878+
879+
if (
880+
Number.isFinite(activeLimit) &&
881+
effectiveActiveCount >= activeLimit &&
882+
!existingMatchingRequest
883+
) {
884+
const message = `You already have ${activeLimit} active request${activeLimit === 1 ? "" : "s"} in the playlist.`;
885+
await deps.createRequestLog(env, {
886+
channelId: channel.id,
887+
twitchMessageId: event.messageId,
888+
twitchUserId: requesterIdentity.twitchUserId,
889+
requesterLogin: requesterIdentity.login,
890+
requesterDisplayName: requesterIdentity.displayName,
891+
rawMessage: event.rawMessage,
892+
normalizedQuery: parsed.query,
893+
outcome: "rejected",
894+
outcomeReason: "active_request_limit",
895+
});
896+
await deps.sendChatReply(env, {
897+
channelId: channel.id,
898+
broadcasterUserId: channel.twitchChannelId,
899+
message,
900+
});
901+
return { body: "Rejected", status: 202 };
902+
}
903+
860904
if (
861905
firstMatch &&
906+
!existingMatchingRequest &&
862907
state.settings.duplicateWindowSeconds > 0 &&
863908
state.logs.some(
864909
(log) =>
@@ -890,7 +935,10 @@ export async function processEventSubChatMessage(input: {
890935
return { body: "Rejected", status: 202 };
891936
}
892937

893-
if (state.items.length >= state.settings.maxQueueSize) {
938+
if (
939+
state.items.length >= state.settings.maxQueueSize &&
940+
!existingMatchingRequest
941+
) {
894942
const effectiveQueueCount = isEditCommand
895943
? Math.max(0, state.items.length - existingActiveCount)
896944
: state.items.length;
@@ -921,6 +969,69 @@ export async function processEventSubChatMessage(input: {
921969
}
922970

923971
try {
972+
if (
973+
existingMatchingRequest &&
974+
existingMatchingRequest.requestKind !== (isVipCommand ? "vip" : "regular")
975+
) {
976+
await deps.changeRequestKind(env, {
977+
channelId: channel.id,
978+
itemId: existingMatchingRequest.id,
979+
requestKind: isVipCommand ? "vip" : "regular",
980+
});
981+
982+
await deps.createRequestLog(env, {
983+
channelId: channel.id,
984+
twitchMessageId: event.messageId,
985+
twitchUserId: requesterIdentity.twitchUserId,
986+
requesterLogin: requesterIdentity.login,
987+
requesterDisplayName: requesterIdentity.displayName,
988+
rawMessage: event.rawMessage,
989+
normalizedQuery: parsed.query,
990+
matchedSongId: firstMatch?.id ?? null,
991+
matchedSongTitle: firstMatch?.title ?? null,
992+
matchedSongArtist: firstMatch?.artist ?? null,
993+
outcome: "accepted",
994+
outcomeReason: isVipCommand
995+
? "vip_request_upgrade"
996+
: "vip_request_downgrade",
997+
});
998+
999+
if (isVipCommand) {
1000+
if (canAutoGrantVipToken) {
1001+
await deps.grantVipToken(env, {
1002+
channelId: channel.id,
1003+
login: requesterIdentity.login,
1004+
displayName: requesterIdentity.displayName,
1005+
twitchUserId: requesterIdentity.twitchUserId,
1006+
autoSubscriberGrant: true,
1007+
});
1008+
}
1009+
1010+
await deps.consumeVipToken(env, {
1011+
channelId: channel.id,
1012+
login: requesterIdentity.login,
1013+
displayName: requesterIdentity.displayName,
1014+
twitchUserId: requesterIdentity.twitchUserId,
1015+
});
1016+
} else {
1017+
await deps.grantVipToken(env, {
1018+
channelId: channel.id,
1019+
login: requesterIdentity.login,
1020+
displayName: requesterIdentity.displayName,
1021+
twitchUserId: requesterIdentity.twitchUserId,
1022+
});
1023+
}
1024+
1025+
await deps.sendChatReply(env, {
1026+
channelId: channel.id,
1027+
broadcasterUserId: channel.twitchChannelId,
1028+
message: isVipCommand
1029+
? `${mention(requesterIdentity.login)} your existing request "${firstMatch ? formatSongForReply(firstMatch) : unmatchedQuery}" is now a VIP request${existingMatchingRequest.status === "current" ? "." : " and will play next."} 1 VIP token was used.`
1030+
: `${mention(requesterIdentity.login)} your existing VIP request "${firstMatch ? formatSongForReply(firstMatch) : unmatchedQuery}" is now a regular request again. 1 VIP token was refunded.`,
1031+
});
1032+
return { body: "Accepted", status: 202 };
1033+
}
1034+
9241035
if (isEditCommand && existingActiveCount > 0) {
9251036
await deps.removeRequestsFromPlaylist(env, {
9261037
channelId: channel.id,
@@ -1148,6 +1259,19 @@ export function createEventSubChatDependencies(): EventSubChatDependencies {
11481259
);
11491260
return (await response.json()) as PlaylistMutationResult;
11501261
},
1262+
changeRequestKind: async (env, input) => {
1263+
const response = await callBackend(env, "/internal/playlist/mutate", {
1264+
method: "POST",
1265+
headers: {
1266+
"content-type": "application/json",
1267+
},
1268+
body: JSON.stringify({
1269+
action: "changeRequestKind",
1270+
...input,
1271+
}),
1272+
});
1273+
return (await response.json()) as PlaylistMutationResult;
1274+
},
11511275
createRequestLog: async (env, input) =>
11521276
createRequestLog(env, input as never),
11531277
createAuditLog: async (env, input) => createAuditLog(env, input as never),

src/lib/playlist/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ export interface RemoveRequestsInput {
3434
kind: "regular" | "vip" | "all";
3535
}
3636

37+
export interface ChangeRequestKindInput {
38+
channelId: string;
39+
itemId: string;
40+
actorUserId: string | null;
41+
requestKind: "regular" | "vip";
42+
}
43+
3744
export interface MarkPlayedInput {
3845
channelId: string;
3946
itemId: string;
@@ -126,6 +133,9 @@ export interface PlaylistMutationResult {
126133

127134
export interface PlaylistCoordinator {
128135
addRequest(input: AddRequestInput): Promise<PlaylistMutationResult>;
136+
changeRequestKind(
137+
input: ChangeRequestKindInput
138+
): Promise<PlaylistMutationResult>;
129139
removeRequests(input: RemoveRequestsInput): Promise<PlaylistMutationResult>;
130140
markPlayed(input: MarkPlayedInput): Promise<PlaylistMutationResult>;
131141
restorePlayed(input: RestorePlayedInput): Promise<PlaylistMutationResult>;

src/lib/validation.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,11 @@ export const playlistMutationSchema = z.discriminatedUnion("action", [
291291
z.object({ action: z.literal("restorePlayed"), playedSongId: z.string() }),
292292
z.object({ action: z.literal("setCurrent"), itemId: z.string() }),
293293
z.object({ action: z.literal("deleteItem"), itemId: z.string() }),
294+
z.object({
295+
action: z.literal("changeRequestKind"),
296+
itemId: z.string(),
297+
requestKind: z.enum(["regular", "vip"]),
298+
}),
294299
z.object({
295300
action: z.literal("chooseVersion"),
296301
itemId: z.string(),

src/routes/$slug/index.tsx

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -164,28 +164,6 @@ function PublicChannelPage() {
164164
const vipAutomationSummary = getVipAutomationSummary(
165165
data?.playlist.settings ?? {}
166166
);
167-
const publicSearchResultFilter = useMemo(
168-
() => (song: SearchSong) =>
169-
showBlacklisted ||
170-
getBlacklistReasons(
171-
{
172-
songCatalogSourceId: song.sourceId ?? null,
173-
songArtist: song.artist ?? null,
174-
songCreator: song.creator ?? null,
175-
},
176-
{
177-
artists: data?.playlist.blacklistArtists ?? [],
178-
charters: data?.playlist.blacklistCharters ?? [],
179-
songs: data?.playlist.blacklistSongs ?? [],
180-
}
181-
).length === 0,
182-
[
183-
data?.playlist.blacklistArtists,
184-
data?.playlist.blacklistCharters,
185-
data?.playlist.blacklistSongs,
186-
showBlacklisted,
187-
]
188-
);
189167
const publicSearchResultState = useMemo(
190168
() =>
191169
(song: SearchSong): SearchSongResultState => {
@@ -285,8 +263,12 @@ function PublicChannelPage() {
285263
title="Search to add a song"
286264
description="Copy the request command and use it in Twitch chat."
287265
placeholder={`Search songs for ${channelDisplayName}`}
288-
resultFilter={publicSearchResultFilter}
266+
extraSearchParams={{
267+
channelSlug: slug,
268+
showBlacklisted,
269+
}}
289270
resultState={publicSearchResultState}
271+
useTotalForSummary
290272
advancedFiltersContent={
291273
<div className="inline-flex flex-wrap items-center gap-3 rounded-full border border-(--border) bg-(--panel) px-4 py-2.5">
292274
<Checkbox

0 commit comments

Comments
 (0)