The chat composer inserted comms_messages straight from the browser with CLIENT-SUPPLIED user_id/display_name/handle/space_id — a member could forge another person's name/handle. Add a sendMessage server action that derives identity from the session, verifies the channel belongs to the caller's space, validates content, and logs the send. The optimistic insert + realtime subscription are preserved, so latency is unchanged. loadMessages stays on the browser client (the realtime subscription needs it) with a comment noting it. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
138 lines
4.3 KiB
TypeScript
138 lines
4.3 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { requireMember, requireMemberWithRole, logActivity, parseInput } from '@/lib/auth-helpers'
|
|
import { createChannelSchema, sendMessageSchema, uuidSchema } from '@/lib/validations'
|
|
|
|
export async function createChannel(input: {
|
|
name: string
|
|
description?: string | null
|
|
channel_type?: 'general' | 'area' | 'ops' | 'project'
|
|
}) {
|
|
const v = parseInput(createChannelSchema, input)
|
|
if (!v.ok) return { error: v.error }
|
|
|
|
const supabase = await createClient()
|
|
const auth = await requireMember(supabase)
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) return { error: 'Not authenticated' }
|
|
|
|
// Uniqueness check: channel names must be unique within a space.
|
|
const { data: existing } = await supabase
|
|
.from('comms_channels')
|
|
.select('id')
|
|
.eq('space_id', member.space_id)
|
|
.eq('name', v.data.name)
|
|
.maybeSingle()
|
|
if (existing) return { error: `A channel named "${v.data.name}" already exists.` }
|
|
|
|
const { data, error } = await supabase
|
|
.from('comms_channels')
|
|
.insert({
|
|
space_id: member.space_id,
|
|
name: v.data.name,
|
|
description: v.data.description ?? null,
|
|
channel_type: v.data.channel_type ?? 'general',
|
|
created_by: user.id,
|
|
is_default: false,
|
|
})
|
|
.select('id')
|
|
.single()
|
|
|
|
if (error) return { error: error.message }
|
|
revalidatePath('/comms')
|
|
return { id: data.id }
|
|
}
|
|
|
|
export async function deleteChannel(channelId: string) {
|
|
const v = parseInput(uuidSchema, channelId)
|
|
if (!v.ok) return { error: 'Invalid channel ID' }
|
|
|
|
const supabase = await createClient()
|
|
const auth = await requireMember(supabase)
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
// Default channels are protected at the RLS layer too.
|
|
const { error, count } = await supabase
|
|
.from('comms_channels')
|
|
.delete({ count: 'exact' })
|
|
.eq('id', v.data)
|
|
.eq('space_id', member.space_id)
|
|
|
|
if (error) return { error: error.message }
|
|
if (count === 0) return { error: 'Cannot delete this channel' }
|
|
revalidatePath('/comms')
|
|
return { success: true as const }
|
|
}
|
|
|
|
export async function sendMessage(input: { channel_id: string; content: string }) {
|
|
const v = parseInput(sendMessageSchema, input)
|
|
if (!v.ok) return { error: v.error }
|
|
|
|
const supabase = await createClient()
|
|
const auth = await requireMember(supabase)
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) return { error: 'Not authenticated' }
|
|
|
|
// The channel must belong to the caller's space, or a forged channel_id
|
|
// could post into another tenant's channel.
|
|
const { data: channel } = await supabase
|
|
.from('comms_channels')
|
|
.select('id')
|
|
.eq('id', v.data.channel_id)
|
|
.eq('space_id', member.space_id)
|
|
.maybeSingle()
|
|
if (!channel) return { error: 'Channel not found' }
|
|
|
|
// Identity is derived from the session — never from client input.
|
|
const { data, error } = await supabase
|
|
.from('comms_messages')
|
|
.insert({
|
|
channel_id: v.data.channel_id,
|
|
space_id: member.space_id,
|
|
user_id: user.id,
|
|
display_name: member.display_name,
|
|
handle: member.handle,
|
|
content: v.data.content,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) return { error: error.message }
|
|
|
|
await logActivity(supabase, member, 'message.send', 'comms_message', data.id)
|
|
|
|
revalidatePath('/comms')
|
|
return { data }
|
|
}
|
|
|
|
export async function renameChannel(channelId: string, name: string, description?: string | null) {
|
|
const idCheck = parseInput(uuidSchema, channelId)
|
|
if (!idCheck.ok) return { error: 'Invalid channel ID' }
|
|
const v = parseInput(createChannelSchema, { name, description })
|
|
if (!v.ok) return { error: v.error }
|
|
|
|
const supabase = await createClient()
|
|
const auth = await requireMember(supabase)
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
const { error } = await supabase
|
|
.from('comms_channels')
|
|
.update({ name: v.data.name, description: v.data.description ?? null })
|
|
.eq('id', idCheck.data)
|
|
.eq('space_id', member.space_id)
|
|
|
|
if (error) return { error: error.message }
|
|
revalidatePath('/comms')
|
|
return { success: true as const }
|
|
}
|