mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-05 16:46:50 +00:00
feat(notif): initial custom channel sending
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -50,4 +50,6 @@ slack-import-emojis/target
|
||||
|
||||
.idea
|
||||
|
||||
/apps/docs/src/content/docs/typedoc-sdk
|
||||
/apps/docs/src/content/docs/typedoc-sdk
|
||||
|
||||
.codex
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
editStreamInfo,
|
||||
changeUsername,
|
||||
updateChatModeration,
|
||||
updateNotificationChannels,
|
||||
} from '@/lib/form/actions';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
|
||||
import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface ChannelSettingsClientProps {
|
||||
channel: Channel & {
|
||||
@@ -107,7 +109,6 @@ interface ChannelSettingsClientProps {
|
||||
export default function ChannelSettingsClient({
|
||||
channel,
|
||||
isOwner,
|
||||
currentUser,
|
||||
isPersonal,
|
||||
}: ChannelSettingsClientProps) {
|
||||
const confirm = useConfirm();
|
||||
@@ -143,6 +144,12 @@ export default function ChannelSettingsClient({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNotifChannelsActionComplete = useCallback((result: any) => {
|
||||
if (result?.success) {
|
||||
toast.success('Notification channels updated');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUsernameChangeComplete = useCallback(
|
||||
(result: any) => {
|
||||
if (result?.success && result?.newUsername) {
|
||||
@@ -276,9 +283,9 @@ export default function ChannelSettingsClient({
|
||||
<MessageSquareWarning className="h-4 w-4" />
|
||||
Moderation
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="utilities" className="flex items-center gap-2">
|
||||
<TabsTrigger value="integrations" className="flex items-center gap-2">
|
||||
<Wrench className="size-4" />
|
||||
Utilities
|
||||
Integrations
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -1053,11 +1060,13 @@ export default function ChannelSettingsClient({
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="utilities">
|
||||
<TabsContent value="integrations">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Utilities</CardTitle>
|
||||
<CardDescription>OBS overlays, APIs... everything in one neat place!</CardDescription>
|
||||
<CardTitle>Integrations</CardTitle>
|
||||
<CardDescription>
|
||||
OBS overlays, Slack... everything in one neat place!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
@@ -1075,11 +1084,58 @@ export default function ChannelSettingsClient({
|
||||
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setKeyVisible(!keyVisible)}>
|
||||
{keyVisible ? 'Hide' : 'Show'}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="smicon"
|
||||
onClick={() => setKeyVisible(!keyVisible)}
|
||||
>
|
||||
{keyVisible ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'channelId', type: 'hidden', value: channel.id, label: 'Channel ID' },
|
||||
{
|
||||
name: 'channels',
|
||||
label: 'Notification channels',
|
||||
value: channel.notifChannels.join('\n'),
|
||||
textArea: true,
|
||||
textAreaRows: 4,
|
||||
description:
|
||||
'One channel ID per line. These channels will receive notifications when you go live.',
|
||||
component: ({ field }) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Notification channels
|
||||
</label>
|
||||
<Textarea
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={
|
||||
typeof field.value === 'string'
|
||||
? field.value
|
||||
: Array.isArray(field.value)
|
||||
? field.value.join('\n')
|
||||
: ''
|
||||
}
|
||||
rows={4}
|
||||
placeholder="Enter channel IDs, one per line"
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Enter one channel ID per line. You can find channel IDs in their URLs.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
schemaName="updateNotificationChannels"
|
||||
action={updateNotificationChannels}
|
||||
submitText="Save notification channels"
|
||||
onActionComplete={handleNotifChannelsActionComplete}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
streamInfoEditSchema,
|
||||
updateChatModerationSchema,
|
||||
updateChannelSettingsSchema,
|
||||
updateNotificationChannelsSchema,
|
||||
} from '@/lib/form/zod';
|
||||
|
||||
export const schemaDb = [
|
||||
@@ -39,6 +40,7 @@ export const schemaDb = [
|
||||
{ name: 'editBot', zod: editBotSchema },
|
||||
{ name: 'changeUsername', zod: changeUsernameSchema },
|
||||
{ name: 'updateChatModeration', zod: updateChatModerationSchema },
|
||||
{ name: 'updateNotificationChannels', zod: updateNotificationChannelsSchema },
|
||||
] as const;
|
||||
|
||||
export function UniversalForm<T extends z.ZodType>({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
streamInfoEditSchema,
|
||||
updateChatModerationSchema,
|
||||
updateChannelSettingsSchema,
|
||||
updateNotificationChannelsSchema,
|
||||
} from './zod';
|
||||
import { initializeStreamInfo } from '../instrumentation/streamInfo';
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
import { can } from '../auth/abac';
|
||||
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
|
||||
import { generateStreamKey } from '../db/streamKey';
|
||||
import slackNotifierClient from '../services/slackNotifier';
|
||||
|
||||
export async function editStreamInfo(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
@@ -791,3 +793,56 @@ export async function changeUsername(prev: any, formData: FormData) {
|
||||
return { success: false, error: 'Failed to change username. Please try again.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateNotificationChannels(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const zod = await zodVerify(updateNotificationChannelsSchema, formData);
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: zod.data.channelId },
|
||||
include: {
|
||||
owner: true,
|
||||
managers: true,
|
||||
streamInfo: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
if (!can(user, 'update', 'channel', { channel })) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const newDifference = zod.data.channels.filter((c: string) => !channel.notifChannels.includes(c));
|
||||
for (const channelId of newDifference) {
|
||||
try {
|
||||
await slackNotifierClient.chat.postMessage({
|
||||
channel: channelId,
|
||||
text: `:yay: I'll send livestream notifications for <https://hackclub.tv/${channel.name}|${channel.name}> here from now on!`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to validate Slack notification channel:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to send a test notification to ${channelId}. Check that the channel ID is valid and that the bot has access, then try again.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.channel.updateMany({
|
||||
where: { id: channel.id },
|
||||
data: { notifChannels: zod.data.channels },
|
||||
});
|
||||
|
||||
revalidatePath(`/settings/channel/${channel.name}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -58,3 +58,24 @@ export const changeUsernameSchema = z.object({
|
||||
channelId: z.string().min(1),
|
||||
newUsername: username,
|
||||
});
|
||||
|
||||
const notificationChannelsSchema: z.ZodType<string[], z.ZodTypeDef, string | string[]> =
|
||||
z.preprocess(
|
||||
(value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.map((channel) => channel.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
z.array(z.string()).max(10)
|
||||
);
|
||||
|
||||
export const updateNotificationChannelsSchema = z.object({
|
||||
channelId: z.string().min(1),
|
||||
channels: notificationChannelsSchema,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { ZodType } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
type SuccessResult<T> = {
|
||||
success: true;
|
||||
@@ -14,7 +14,10 @@ type ErrorResult = {
|
||||
|
||||
type VerifyResult<T> = SuccessResult<T> | ErrorResult;
|
||||
|
||||
export default async function zodVerify<T>(schema: ZodType<T>, data: FormData | Object): Promise<VerifyResult<T>> {
|
||||
export default async function zodVerify<TSchema extends z.ZodTypeAny>(
|
||||
schema: TSchema,
|
||||
data: FormData | Object
|
||||
): Promise<VerifyResult<z.output<TSchema>>> {
|
||||
let obj: any = data;
|
||||
if (data instanceof FormData) {
|
||||
obj = Object.fromEntries(data.entries());
|
||||
@@ -36,4 +39,4 @@ export default async function zodVerify<T>(schema: ZodType<T>, data: FormData |
|
||||
success: true,
|
||||
data: zod.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Channel" ADD COLUMN "notifChannels" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -57,6 +57,7 @@ model Channel {
|
||||
name String @unique
|
||||
description String @default("A hctv channel")
|
||||
pfpUrl String
|
||||
notifChannels String[] @default([])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
Reference in New Issue
Block a user