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
10 changes: 5 additions & 5 deletions apps/discord-gateway/src/mediaControlRouting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ describe("Discord media control routing", () => {
it("maps Discord control actions to the shared event media command model", () => {
assert.equal(eventMediaCommandTypeForDiscordAction("start"), "start_program");
assert.equal(eventMediaCommandTypeForDiscordAction("stop"), "stop_program");
assert.equal(eventMediaCommandTypeForDiscordAction("hold"), "switch_hold");
assert.equal(eventMediaCommandTypeForDiscordAction("next"), "next_slot");
assert.equal(eventMediaCommandTypeForDiscordAction("previous"), "previous_slot");
assert.equal(eventMediaCommandTypeForDiscordAction("hold"), "hold_current");
assert.equal(eventMediaCommandTypeForDiscordAction("next"), "switch_next");
assert.equal(eventMediaCommandTypeForDiscordAction("previous"), "switch_previous");
assert.equal(eventMediaCommandTypeForDiscordAction("source"), "switch_source");
assert.equal(eventMediaCommandTypeForDiscordAction("fallback"), "force_direct_link_fallback");
assert.equal(eventMediaCommandTypeForDiscordAction("fallback"), "publish_fallback_link");
assert.equal(eventMediaCommandTypeForDiscordAction("publish_watch_link"), "publish_current_public_watch_link");
assert.equal(eventMediaCommandTypeForDiscordAction("refresh"), undefined);
});
Expand All @@ -43,7 +43,7 @@ describe("Discord media control routing", () => {
assert.equal(route.ack, "defer_message_update");
assert.equal(route.eventId, "event_123");
assert.equal(route.requiresConfirmation, false);
assert.deepEqual(route.command, { type: "switch_hold" });
assert.deepEqual(route.command, { type: "hold_current" });
});

it("routes stale panel component clicks to ephemeral warnings without commands", () => {
Expand Down
8 changes: 4 additions & 4 deletions apps/discord-gateway/src/mediaControlRouting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ type CommandAction = Exclude<DiscordMediaControlAction, "refresh">;
const ACTION_TO_COMMAND_TYPE = {
start: "start_program",
stop: "stop_program",
hold: "switch_hold",
next: "next_slot",
previous: "previous_slot",
hold: "hold_current",
next: "switch_next",
previous: "switch_previous",
source: "switch_source",
fallback: "force_direct_link_fallback",
fallback: "publish_fallback_link",
publish_watch_link: "publish_current_public_watch_link",
} as const satisfies Record<CommandAction, EventMediaCommandType>;

Expand Down
32 changes: 29 additions & 3 deletions convex/_communityAuthority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,28 @@ type IdentityLike = {
name?: string;
};

type CommunityCapability =
export type CommunityCapability =
| "edit_community_profile"
| "manage_profile"
| "manage_roster"
| "manage_events"
| "manage_event_media"
| "view_event_operations"
| "manage_staff"
| "manage_integrations"
| "manage_billing";

const communityCapabilityAliases: Partial<Record<CommunityCapability, CommunityCapability[]>> = {
edit_community_profile: ["manage_profile"],
manage_profile: ["edit_community_profile"],
};

function hasCapability(capabilities: CommunityCapability[], requested: CommunityCapability): boolean {
const accepted = [requested, ...(communityCapabilityAliases[requested] ?? [])];

return accepted.some((capability) => capabilities.includes(capability));
}

export function toAuthSubject(identity: IdentityLike): AuthSubject {
return {
tokenIdentifier: identity.tokenIdentifier,
Expand All @@ -40,6 +55,15 @@ export async function subjectHasCommunityCapability(
communityProfileId: Id<"profiles">,
subject: AuthSubject,
capability: CommunityCapability,
): Promise<boolean> {
return subjectHasAnyCommunityCapability(db, communityProfileId, subject, [capability]);
}

export async function subjectHasAnyCommunityCapability(
db: DatabaseReader,
communityProfileId: Id<"profiles">,
subject: AuthSubject,
capabilities: CommunityCapability[],
): Promise<boolean> {
const authorities = await db
.query("communityAuthorities")
Expand All @@ -49,7 +73,9 @@ export async function subjectHasCommunityCapability(
.eq("state", "active")
.eq("communityProfileId", communityProfileId),
)
.take(1);
.take(20);

return authorities.some((authority) => authority.capabilities.includes(capability));
return authorities.some((authority) =>
capabilities.some((capability) => hasCapability(authority.capabilities as CommunityCapability[], capability)),
);
}
31 changes: 28 additions & 3 deletions convex/_eventMediaControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export type EventMediaSourcePurpose =
| "fallback"
| "watch";

export type EventMediaSourceState = "draft" | "ready" | "live" | "offline" | "failed" | "disabled";
export type EventMediaSourceState = "draft" | "ready" | "live" | "offline" | "stale" | "unknown" | "failed" | "disabled";

export type EventMediaSceneType = "source" | "hold_slate" | "intro" | "outro" | "offline_card" | "countdown";

Expand All @@ -68,7 +68,13 @@ export type EventMediaComplianceGateState = "pending" | "accepted" | "blocked";
export type EventMediaCommandType =
| "start_program"
| "stop_program"
| "preview_source"
| "switch_next"
| "switch_previous"
| "switch_source"
| "hold_current"
| "show_hold_scene"
| "publish_fallback_link"
| "switch_hold"
| "next_slot"
| "previous_slot"
Expand Down Expand Up @@ -185,6 +191,7 @@ export type SanitizedVrcdnOperatorOwnedOutputSetup = {
export type EventMediaCommandInput = {
type: EventMediaCommandType;
targetSourceKey?: string;
targetSceneKey?: string;
targetOutputKey?: string;
publicFallbackLinks?: EventMediaPublicLinkInput[];
note?: string;
Expand All @@ -193,6 +200,7 @@ export type EventMediaCommandInput = {
export type SanitizedEventMediaCommand = {
type: EventMediaCommandType;
targetSourceKey?: string;
targetSceneKey?: string;
targetOutputKey?: string;
publicFallbackLinks: EventMediaPublicLink[];
note?: string;
Expand Down Expand Up @@ -262,6 +270,8 @@ export const eventMediaSourceStateValidator = v.union(
v.literal("ready"),
v.literal("live"),
v.literal("offline"),
v.literal("stale"),
v.literal("unknown"),
v.literal("failed"),
v.literal("disabled"),
);
Expand Down Expand Up @@ -312,7 +322,13 @@ export const eventMediaComplianceGateStateValidator = v.union(
export const eventMediaCommandTypeValidator = v.union(
v.literal("start_program"),
v.literal("stop_program"),
v.literal("preview_source"),
v.literal("switch_next"),
v.literal("switch_previous"),
v.literal("switch_source"),
v.literal("hold_current"),
v.literal("show_hold_scene"),
v.literal("publish_fallback_link"),
v.literal("switch_hold"),
v.literal("next_slot"),
v.literal("previous_slot"),
Expand Down Expand Up @@ -616,21 +632,30 @@ export function sanitizeEventMediaWorkerArtifactLinks(

export function sanitizeEventMediaCommandInput(input: EventMediaCommandInput): SanitizedEventMediaCommand {
const targetSourceKey = sanitizeControlKey(input.targetSourceKey, "Target source key");
const targetSceneKey = sanitizeControlKey(input.targetSceneKey, "Target scene key");
const targetOutputKey = sanitizeControlKey(input.targetOutputKey, "Target output key");
const publicFallbackLinks = sanitizeEventMediaPublicLinks(input.publicFallbackLinks);
const note = optionalBoundedText(input.note, "Media command note", CONTROL_NOTE_MAX_LENGTH);

if (["switch_source", "mark_source_live", "mark_source_offline"].includes(input.type) && targetSourceKey === undefined) {
if (
["preview_source", "switch_source", "mark_source_live", "mark_source_offline"].includes(input.type) &&
targetSourceKey === undefined
) {
throw new Error(`${input.type} requires a target source key.`);
}

if (input.type === "force_direct_link_fallback" && publicFallbackLinks.length === 0) {
if (input.type === "show_hold_scene" && targetSceneKey === undefined) {
throw new Error("show_hold_scene requires a target scene key.");
}

if (["force_direct_link_fallback", "publish_fallback_link"].includes(input.type) && publicFallbackLinks.length === 0) {
throw new Error("Direct-link fallback requires at least one public fallback link.");
}

return {
type: input.type,
...(targetSourceKey === undefined ? {} : { targetSourceKey }),
...(targetSceneKey === undefined ? {} : { targetSceneKey }),
...(targetOutputKey === undefined ? {} : { targetOutputKey }),
publicFallbackLinks,
...(note === undefined ? {} : { note }),
Expand Down
43 changes: 43 additions & 0 deletions convex/_eventOperations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Doc } from "./_generated/dataModel";

export function eventOperationSlot(slot: Doc<"eventSlots">) {
return {
slotId: slot._id,
position: slot.position,
startAt: slot.startAt,
...(slot.endAt === undefined ? {} : { endAt: slot.endAt }),
displayLabel: slot.displayLabel,
roleLabel: slot.roleLabel,
reviewState: slot.reviewState,
};
}

export function findEventOperationSlots(slots: Doc<"eventSlots">[], now: number) {
const ordered = [...slots].sort((first, second) => first.startAt - second.startAt || first.position - second.position);
let currentSlot: Doc<"eventSlots"> | undefined;
let nextSlot: Doc<"eventSlots"> | undefined;

for (let index = 0; index < ordered.length; index += 1) {
const slot = ordered[index];

if (slot === undefined) {
continue;
}

if (nextSlot === undefined && slot.startAt > now) {
nextSlot = slot;
}

const followingSlot = ordered[index + 1];
const effectiveEndAt = slot.endAt ?? followingSlot?.startAt;

if (slot.startAt <= now && (effectiveEndAt === undefined || effectiveEndAt > now)) {
currentSlot = slot;
}
}

return {
...(currentSlot === undefined ? {} : { currentSlot: eventOperationSlot(currentSlot) }),
...(nextSlot === undefined ? {} : { nextSlot: eventOperationSlot(nextSlot) }),
};
}
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type * as _communityAuthority from "../_communityAuthority.js";
import type * as _discordTimestamps from "../_discordTimestamps.js";
import type * as _eventInputs from "../_eventInputs.js";
import type * as _eventMediaControl from "../_eventMediaControl.js";
import type * as _eventOperations from "../_eventOperations.js";
import type * as _eventPublic from "../_eventPublic.js";
import type * as _eventSlots from "../_eventSlots.js";
import type * as _eventSlugs from "../_eventSlugs.js";
Expand Down Expand Up @@ -61,6 +62,7 @@ declare const fullApi: ApiFromModules<{
_discordTimestamps: typeof _discordTimestamps;
_eventInputs: typeof _eventInputs;
_eventMediaControl: typeof _eventMediaControl;
_eventOperations: typeof _eventOperations;
_eventPublic: typeof _eventPublic;
_eventSlots: typeof _eventSlots;
_eventSlugs: typeof _eventSlugs;
Expand Down
Loading