Files
metabuilder/packages/irc_webchat/seed/scripts/db/operations.lua
2025-12-30 21:45:27 +00:00

418 lines
11 KiB
Lua

-- irc_webchat/seed/scripts/db/operations.lua
-- DBAL operations for IRC entities (Channel, Message, Membership)
-- @module irc_webchat.db.operations
local M = {}
local json = require('json')
---------------------------------------------------------------------------
-- CHANNEL OPERATIONS
---------------------------------------------------------------------------
---@class IRCChannelCreateParams
---@field tenantId string
---@field name string Must start with #
---@field displayName string|nil
---@field description string|nil
---@field topic string|nil
---@field minLevel number|nil
---@field creatorId string
---Create a new IRC channel
---@param dbal table DBAL client instance
---@param params IRCChannelCreateParams
---@return table Created channel
function M.createChannel(dbal, params)
-- Validate channel name starts with #
local name = params.name
if name:sub(1, 1) ~= '#' then
name = '#' .. name
end
-- Check for duplicate channel name
local existing = dbal:findFirst('IRCChannel', {
where = { tenantId = params.tenantId, name = name },
})
if existing then
error('Channel already exists: ' .. name)
end
return dbal:create('IRCChannel', {
tenantId = params.tenantId,
name = name,
displayName = params.displayName or name:sub(2),
description = params.description,
topic = params.topic or 'Welcome to ' .. name,
minLevel = params.minLevel or 0,
isPrivate = false,
isArchived = false,
creatorId = params.creatorId,
memberCount = 0,
messageCount = 0,
createdAt = os.time() * 1000,
updatedAt = os.time() * 1000,
lastActivityAt = os.time() * 1000,
})
end
---Get channel by name
---@param dbal table
---@param tenantId string
---@param name string
---@return table|nil Channel
function M.getChannelByName(dbal, tenantId, name)
if name:sub(1, 1) ~= '#' then
name = '#' .. name
end
return dbal:findFirst('IRCChannel', {
where = { tenantId = tenantId, name = name },
})
end
---List all channels
---@param dbal table
---@param tenantId string
---@param includeArchived boolean|nil
---@return table[] Channels
function M.listChannels(dbal, tenantId, includeArchived)
local result = dbal:list('IRCChannel', {
where = { tenantId = tenantId },
orderBy = { memberCount = 'desc' },
take = 100,
})
local channels = result.items or {}
if not includeArchived then
local active = {}
for _, ch in ipairs(channels) do
if not ch.isArchived then
table.insert(active, ch)
end
end
channels = active
end
return channels
end
---Update channel topic
---@param dbal table
---@param channelId string
---@param topic string
---@param userId string Who set the topic
---@return table Updated channel
function M.setTopic(dbal, channelId, topic, userId)
return dbal:update('IRCChannel', channelId, {
topic = topic,
topicSetBy = userId,
topicSetAt = os.time() * 1000,
updatedAt = os.time() * 1000,
})
end
---Archive a channel
---@param dbal table
---@param channelId string
function M.archiveChannel(dbal, channelId)
return dbal:update('IRCChannel', channelId, {
isArchived = true,
updatedAt = os.time() * 1000,
})
end
---------------------------------------------------------------------------
-- MESSAGE OPERATIONS
---------------------------------------------------------------------------
---@class IRCMessageCreateParams
---@field tenantId string
---@field channelId string
---@field userId string
---@field username string
---@field displayName string|nil
---@field type string message|join|part|action|notice|system
---@field content string
---@field replyToId string|nil
---Send a message to a channel
---@param dbal table DBAL client instance
---@param params IRCMessageCreateParams
---@return table Created message
function M.sendMessage(dbal, params)
local message = dbal:create('IRCMessage', {
tenantId = params.tenantId,
channelId = params.channelId,
userId = params.userId,
username = params.username,
displayName = params.displayName or params.username,
type = params.type or 'message',
content = params.content,
replyToId = params.replyToId,
timestamp = os.time() * 1000,
})
-- Update channel stats
local channel = dbal:read('IRCChannel', params.channelId)
if channel then
dbal:update('IRCChannel', params.channelId, {
messageCount = (channel.messageCount or 0) + 1,
lastActivityAt = os.time() * 1000,
updatedAt = os.time() * 1000,
})
end
return message
end
---Get channel messages (most recent first)
---@param dbal table
---@param channelId string
---@param take number|nil
---@param before number|nil Timestamp - get messages before this time
---@return table[] Messages
function M.getMessages(dbal, channelId, take, before)
local result = dbal:list('IRCMessage', {
where = { channelId = channelId },
orderBy = { timestamp = 'desc' },
take = take or 50,
})
local messages = result.items or {}
-- Filter by timestamp if specified
if before then
local filtered = {}
for _, msg in ipairs(messages) do
if msg.timestamp < before then
table.insert(filtered, msg)
end
end
messages = filtered
end
-- Reverse to get chronological order for display
local reversed = {}
for i = #messages, 1, -1 do
table.insert(reversed, messages[i])
end
return reversed
end
---Send a system message
---@param dbal table
---@param tenantId string
---@param channelId string
---@param content string
function M.sendSystemMessage(dbal, tenantId, channelId, content)
return M.sendMessage(dbal, {
tenantId = tenantId,
channelId = channelId,
userId = 'system',
username = '*system*',
type = 'system',
content = content,
})
end
---Delete a message (mark as deleted)
---@param dbal table
---@param messageId string
---@param deletedBy string
function M.deleteMessage(dbal, messageId, deletedBy)
return dbal:update('IRCMessage', messageId, {
content = '[deleted by ' .. deletedBy .. ']',
type = 'system',
})
end
---------------------------------------------------------------------------
-- MEMBERSHIP OPERATIONS
---------------------------------------------------------------------------
---@class IRCMembershipParams
---@field tenantId string
---@field channelId string
---@field userId string
---@field username string
---@field role string owner|operator|voiced|member
---Join a channel
---@param dbal table DBAL client instance
---@param params IRCMembershipParams
---@return table Membership record
function M.joinChannel(dbal, params)
-- Check if already a member
local existing = dbal:findFirst('IRCMembership', {
where = {
channelId = params.channelId,
userId = params.userId,
},
})
if existing then
-- Update last seen
return dbal:update('IRCMembership', existing.id, {
isOnline = true,
lastSeenAt = os.time() * 1000,
})
end
local membership = dbal:create('IRCMembership', {
tenantId = params.tenantId,
channelId = params.channelId,
userId = params.userId,
username = params.username,
role = params.role or 'member',
isOnline = true,
joinedAt = os.time() * 1000,
lastSeenAt = os.time() * 1000,
})
-- Update channel member count
local channel = dbal:read('IRCChannel', params.channelId)
if channel then
dbal:update('IRCChannel', params.channelId, {
memberCount = (channel.memberCount or 0) + 1,
updatedAt = os.time() * 1000,
})
end
-- Send join message
M.sendMessage(dbal, {
tenantId = params.tenantId,
channelId = params.channelId,
userId = params.userId,
username = params.username,
type = 'join',
content = params.username .. ' has joined the channel',
})
return membership
end
---Leave a channel
---@param dbal table
---@param channelId string
---@param userId string
---@param message string|nil Part message
function M.leaveChannel(dbal, channelId, userId, message)
local membership = dbal:findFirst('IRCMembership', {
where = { channelId = channelId, userId = userId },
})
if not membership then
return false
end
-- Send part message
local channel = dbal:read('IRCChannel', channelId)
M.sendMessage(dbal, {
tenantId = membership.tenantId,
channelId = channelId,
userId = userId,
username = membership.username,
type = 'part',
content = membership.username .. ' has left' .. (message and (': ' .. message) or ''),
})
-- Delete membership
dbal:delete('IRCMembership', membership.id)
-- Update channel member count
if channel then
dbal:update('IRCChannel', channelId, {
memberCount = math.max(0, (channel.memberCount or 1) - 1),
updatedAt = os.time() * 1000,
})
end
return true
end
---Get channel members
---@param dbal table
---@param channelId string
---@param onlineOnly boolean|nil
---@return table[] Members
function M.getMembers(dbal, channelId, onlineOnly)
local result = dbal:list('IRCMembership', {
where = { channelId = channelId },
orderBy = { role = 'asc', username = 'asc' },
take = 500,
})
local members = result.items or {}
if onlineOnly then
local online = {}
for _, m in ipairs(members) do
if m.isOnline then
table.insert(online, m)
end
end
members = online
end
return members
end
---Set user's online status
---@param dbal table
---@param channelId string
---@param userId string
---@param isOnline boolean
function M.setOnlineStatus(dbal, channelId, userId, isOnline)
local membership = dbal:findFirst('IRCMembership', {
where = { channelId = channelId, userId = userId },
})
if membership then
dbal:update('IRCMembership', membership.id, {
isOnline = isOnline,
lastSeenAt = os.time() * 1000,
})
end
end
---Update user role in channel
---@param dbal table
---@param channelId string
---@param userId string
---@param role string owner|operator|voiced|member
function M.setRole(dbal, channelId, userId, role)
local membership = dbal:findFirst('IRCMembership', {
where = { channelId = channelId, userId = userId },
})
if membership then
dbal:update('IRCMembership', membership.id, { role = role })
end
end
---Get channels a user is in
---@param dbal table
---@param userId string
---@return table[] Channels
function M.getUserChannels(dbal, userId)
local memberships = dbal:list('IRCMembership', {
where = { userId = userId },
take = 100,
})
local channels = {}
for _, m in ipairs(memberships.items or {}) do
local channel = dbal:read('IRCChannel', m.channelId)
if channel and not channel.isArchived then
table.insert(channels, channel)
end
end
return channels
end
return M