416 lines
15 KiB
Go
416 lines
15 KiB
Go
package room
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
|
|
|
func (s *Store) DB() *sql.DB { return s.db }
|
|
|
|
func InitSchema(db *sql.DB) error {
|
|
stmts := []string{
|
|
`CREATE TABLE IF NOT EXISTS rooms_v2 (
|
|
name TEXT PRIMARY KEY,
|
|
display_name TEXT,
|
|
description TEXT,
|
|
creator TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
is_public INTEGER NOT NULL DEFAULT 1,
|
|
password_hash TEXT DEFAULT '',
|
|
require_approval INTEGER NOT NULL DEFAULT 0,
|
|
max_members INTEGER NOT NULL DEFAULT 100,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
closed_at INTEGER,
|
|
settings_json TEXT,
|
|
version INTEGER NOT NULL DEFAULT 1
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS room_members (
|
|
room TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
username TEXT NOT NULL,
|
|
role INTEGER NOT NULL,
|
|
status INTEGER NOT NULL DEFAULT 1,
|
|
added_permissions INTEGER NOT NULL DEFAULT 0,
|
|
removed_permissions INTEGER NOT NULL DEFAULT 0,
|
|
admin_added_permissions INTEGER NOT NULL DEFAULT 0,
|
|
admin_removed_permissions INTEGER NOT NULL DEFAULT 0,
|
|
joined_at INTEGER NOT NULL,
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
PRIMARY KEY (room, user_id),
|
|
FOREIGN KEY (room) REFERENCES rooms_v2(name) ON DELETE CASCADE
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_room_members_room_role ON room_members(room, role)`,
|
|
`CREATE TABLE IF NOT EXISTS room_bans (
|
|
room TEXT PRIMARY KEY,
|
|
reason TEXT,
|
|
banned_at INTEGER,
|
|
banned_by TEXT
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS room_join_requests (
|
|
room TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at INTEGER NOT NULL,
|
|
PRIMARY KEY (room, user_id)
|
|
)`,
|
|
}
|
|
for _, stmt := range stmts {
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
return fmt.Errorf("init schema %q: %w", stmt, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) CreateRoom(ctx context.Context, r *Room) error {
|
|
if s.db == nil {
|
|
return fmt.Errorf("no db")
|
|
}
|
|
settingsJSON, _ := json.Marshal(r.Settings)
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO rooms_v2 (name, display_name, description, creator, status, is_public, password_hash, require_approval, max_members, created_at, updated_at, settings_json, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
r.Name, r.DisplayName, r.Description, r.Creator, string(r.Status), boolToInt(r.IsPublic), r.PasswordHash, boolToInt(r.RequireApproval), r.MaxMembers, r.CreatedAt.Unix(), r.UpdatedAt.Unix(), string(settingsJSON), r.Version)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) GetRoom(ctx context.Context, name string) (*Room, error) {
|
|
if s.db == nil {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
row := s.db.QueryRowContext(ctx, `SELECT name, display_name, description, creator, status, is_public, password_hash, require_approval, max_members, created_at, updated_at, closed_at, settings_json, version FROM rooms_v2 WHERE name = ?`, name)
|
|
var r Room
|
|
var status string
|
|
var isPublic, requireApproval int
|
|
var createdAt, updatedAt int64
|
|
var closedAt sql.NullInt64
|
|
var settingsJSON sql.NullString
|
|
var displayName, description sql.NullString
|
|
var passwordHash sql.NullString
|
|
if err := row.Scan(&r.Name, &displayName, &description, &r.Creator, &status, &isPublic, &passwordHash, &requireApproval, &r.MaxMembers, &createdAt, &updatedAt, &closedAt, &settingsJSON, &r.Version); err != nil {
|
|
return nil, err
|
|
}
|
|
r.DisplayName = displayName.String
|
|
r.Description = description.String
|
|
r.PasswordHash = passwordHash.String
|
|
r.Status = RoomStatus(status)
|
|
r.IsPublic = isPublic != 0
|
|
r.RequireApproval = requireApproval != 0
|
|
r.CreatedAt = time.Unix(createdAt, 0)
|
|
r.UpdatedAt = time.Unix(updatedAt, 0)
|
|
if closedAt.Valid {
|
|
t := time.Unix(closedAt.Int64, 0)
|
|
r.ClosedAt = &t
|
|
}
|
|
r.Settings = DefaultSettings()
|
|
if settingsJSON.Valid && settingsJSON.String != "" {
|
|
_ = json.Unmarshal([]byte(settingsJSON.String), r.Settings)
|
|
} else {
|
|
r.Settings.IsPublic = r.IsPublic
|
|
r.Settings.RequireApproval = r.RequireApproval
|
|
r.Settings.MaxMembers = r.MaxMembers
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
func (s *Store) ListRooms(ctx context.Context) ([]*Room, error) {
|
|
if s.db == nil {
|
|
return nil, nil
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `SELECT name, display_name, description, creator, status, is_public, require_approval, max_members, created_at, updated_at, closed_at, settings_json, version FROM rooms_v2 WHERE status != 'banned' ORDER BY updated_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*Room
|
|
for rows.Next() {
|
|
var r Room
|
|
var status string
|
|
var isPublic, requireApproval int
|
|
var createdAt, updatedAt int64
|
|
var closedAt sql.NullInt64
|
|
var settingsJSON sql.NullString
|
|
var displayName, description sql.NullString
|
|
if err := rows.Scan(&r.Name, &displayName, &description, &r.Creator, &status, &isPublic, &requireApproval, &r.MaxMembers, &createdAt, &updatedAt, &closedAt, &settingsJSON, &r.Version); err != nil {
|
|
return nil, err
|
|
}
|
|
r.DisplayName = displayName.String
|
|
r.Description = description.String
|
|
r.Status = RoomStatus(status)
|
|
r.IsPublic = isPublic != 0
|
|
r.RequireApproval = requireApproval != 0
|
|
r.CreatedAt = time.Unix(createdAt, 0)
|
|
r.UpdatedAt = time.Unix(updatedAt, 0)
|
|
if closedAt.Valid {
|
|
t := time.Unix(closedAt.Int64, 0)
|
|
r.ClosedAt = &t
|
|
}
|
|
r.Settings = DefaultSettings()
|
|
if settingsJSON.Valid && settingsJSON.String != "" {
|
|
_ = json.Unmarshal([]byte(settingsJSON.String), r.Settings)
|
|
}
|
|
out = append(out, &r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) UpdateRoomSettings(ctx context.Context, name string, settings *RoomSettings) error {
|
|
if s.db == nil {
|
|
return fmt.Errorf("no db")
|
|
}
|
|
b, _ := json.Marshal(settings)
|
|
_, err := s.db.ExecContext(ctx, `UPDATE rooms_v2 SET settings_json = ?, is_public = ?, require_approval = ?, max_members = ?, updated_at = ?, version = version + 1 WHERE name = ?`, string(b), boolToInt(settings.IsPublic), boolToInt(settings.RequireApproval), settings.MaxMembers, time.Now().Unix(), name)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) UpdateRoomStatus(ctx context.Context, name string, status RoomStatus) error {
|
|
if s.db == nil {
|
|
return fmt.Errorf("no db")
|
|
}
|
|
var closedAt *int64
|
|
if status == RoomClosed {
|
|
v := time.Now().Unix()
|
|
closedAt = &v
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `UPDATE rooms_v2 SET status = ?, closed_at = ?, updated_at = ?, version = version + 1 WHERE name = ?`, string(status), closedAt, time.Now().Unix(), name)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteRoom(ctx context.Context, name string) error {
|
|
if s.db == nil {
|
|
return fmt.Errorf("no db")
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM rooms_v2 WHERE name = ?`, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _ = s.db.ExecContext(ctx, `DELETE FROM room_members WHERE room = ?`, name)
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) UpsertMember(ctx context.Context, m *RoomMember) error {
|
|
if s.db == nil {
|
|
return fmt.Errorf("no db")
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO room_members (room, user_id, username, role, status, added_permissions, removed_permissions, admin_added_permissions, admin_removed_permissions, joined_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(room, user_id) DO UPDATE SET role=excluded.role, status=excluded.status, added_permissions=excluded.added_permissions, removed_permissions=excluded.removed_permissions, admin_added_permissions=excluded.admin_added_permissions, admin_removed_permissions=excluded.admin_removed_permissions, version=version+1`,
|
|
m.RoomName, m.UserID, m.Username, int(m.Role), int(m.Status), int64(m.AddedPerms), int64(m.RemovedPerms), int64(m.AdminAdded), int64(m.AdminRemoved), m.JoinedAt.Unix(), m.Version)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) GetMember(ctx context.Context, room, userID string) (*RoomMember, error) {
|
|
if s.db == nil {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
row := s.db.QueryRowContext(ctx, `SELECT room, user_id, username, role, status, added_permissions, removed_permissions, admin_added_permissions, admin_removed_permissions, joined_at, version FROM room_members WHERE room = ? AND user_id = ?`, room, userID)
|
|
var m RoomMember
|
|
var role, status int
|
|
var joinedAt int64
|
|
var added, removed, adminAdded, adminRemoved int64
|
|
if err := row.Scan(&m.RoomName, &m.UserID, &m.Username, &role, &status, &added, &removed, &adminAdded, &adminRemoved, &joinedAt, &m.Version); err != nil {
|
|
return nil, err
|
|
}
|
|
m.Role = RoomRole(role)
|
|
m.Status = MemberStatus(status)
|
|
m.AddedPerms = uint64(added)
|
|
m.RemovedPerms = uint64(removed)
|
|
m.AdminAdded = uint64(adminAdded)
|
|
m.AdminRemoved = uint64(adminRemoved)
|
|
m.JoinedAt = time.Unix(joinedAt, 0)
|
|
m.RoomID = m.RoomName
|
|
return &m, nil
|
|
}
|
|
|
|
func (s *Store) ListMembers(ctx context.Context, room string) ([]*RoomMember, error) {
|
|
if s.db == nil {
|
|
return nil, nil
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `SELECT room, user_id, username, role, status, added_permissions, removed_permissions, admin_added_permissions, admin_removed_permissions, joined_at, version FROM room_members WHERE room = ? ORDER BY role ASC, joined_at ASC`, room)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*RoomMember
|
|
for rows.Next() {
|
|
var m RoomMember
|
|
var role, status int
|
|
var joinedAt int64
|
|
var added, removed, adminAdded, adminRemoved int64
|
|
if err := rows.Scan(&m.RoomName, &m.UserID, &m.Username, &role, &status, &added, &removed, &adminAdded, &adminRemoved, &joinedAt, &m.Version); err != nil {
|
|
return nil, err
|
|
}
|
|
m.Role = RoomRole(role)
|
|
m.Status = MemberStatus(status)
|
|
m.AddedPerms = uint64(added)
|
|
m.RemovedPerms = uint64(removed)
|
|
m.AdminAdded = uint64(adminAdded)
|
|
m.AdminRemoved = uint64(adminRemoved)
|
|
m.JoinedAt = time.Unix(joinedAt, 0)
|
|
m.RoomID = m.RoomName
|
|
out = append(out, &m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) RemoveMember(ctx context.Context, room, userID string) error {
|
|
if s.db == nil {
|
|
return fmt.Errorf("no db")
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM room_members WHERE room = ? AND user_id = ?`, room, userID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) CountMembers(ctx context.Context, room string) (int, error) {
|
|
if s.db == nil {
|
|
return 0, nil
|
|
}
|
|
var cnt int
|
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_members WHERE room = ? AND status = 1`, room).Scan(&cnt)
|
|
return cnt, err
|
|
}
|
|
|
|
|
|
func (s *Store) CountRooms(ctx context.Context, q RoomListQuery) (int, error) {
|
|
if s.db == nil {
|
|
return 0, nil
|
|
}
|
|
query := `SELECT COUNT(*) FROM rooms_v2 WHERE 1=1`
|
|
args := []interface{}{}
|
|
if q.Status != nil {
|
|
query += ` AND status = ?`
|
|
args = append(args, string(*q.Status))
|
|
} else if q.IsBanned != nil && !*q.IsBanned {
|
|
query += ` AND status != 'banned'`
|
|
}
|
|
if q.IsPublic != nil {
|
|
query += ` AND is_public = ?`
|
|
args = append(args, boolToInt(*q.IsPublic))
|
|
}
|
|
if q.Creator != nil && *q.Creator != "" {
|
|
query += ` AND creator = ?`
|
|
args = append(args, *q.Creator)
|
|
}
|
|
if q.Search != nil && *q.Search != "" {
|
|
query += ` AND (name LIKE ? OR display_name LIKE ? OR description LIKE ?)`
|
|
like := "%" + *q.Search + "%"
|
|
args = append(args, like, like, like)
|
|
}
|
|
var cnt int
|
|
err := s.db.QueryRowContext(ctx, query, args...).Scan(&cnt)
|
|
return cnt, err
|
|
}
|
|
|
|
func (s *Store) QueryRooms(ctx context.Context, q RoomListQuery) ([]*Room, int, error) {
|
|
if s.db == nil {
|
|
return nil, 0, nil
|
|
}
|
|
total, err := s.CountRooms(ctx, q)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
query := `SELECT name, display_name, description, creator, status, is_public, require_approval, max_members, created_at, updated_at, closed_at, settings_json, version FROM rooms_v2 WHERE 1=1`
|
|
args := []interface{}{}
|
|
if q.Status != nil {
|
|
query += ` AND status = ?`
|
|
args = append(args, string(*q.Status))
|
|
} else if q.IsBanned != nil && !*q.IsBanned {
|
|
query += ` AND status != 'banned'`
|
|
}
|
|
if q.IsPublic != nil {
|
|
query += ` AND is_public = ?`
|
|
args = append(args, boolToInt(*q.IsPublic))
|
|
}
|
|
if q.Creator != nil && *q.Creator != "" {
|
|
query += ` AND creator = ?`
|
|
args = append(args, *q.Creator)
|
|
}
|
|
if q.Search != nil && *q.Search != "" {
|
|
query += ` AND (name LIKE ? OR display_name LIKE ? OR description LIKE ?)`
|
|
like := "%" + *q.Search + "%"
|
|
args = append(args, like, like, like)
|
|
}
|
|
// Sorting
|
|
sortCol := q.SortBy.AsSQL()
|
|
dir := strings.ToUpper(string(q.SortDirection))
|
|
if dir != "ASC" && dir != "DESC" {
|
|
dir = "DESC"
|
|
}
|
|
// whitelist sortCol
|
|
allowed := map[string]bool{"created_at": true, "updated_at": true, "name": true, "member_count": true}
|
|
if !allowed[sortCol] {
|
|
sortCol = "created_at"
|
|
}
|
|
query += fmt.Sprintf(` ORDER BY %s %s`, sortCol, dir)
|
|
// Pagination
|
|
limit := q.Pagination.Limit()
|
|
offset := q.Pagination.Offset()
|
|
query += ` LIMIT ? OFFSET ?`
|
|
args = append(args, limit, offset)
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*Room
|
|
for rows.Next() {
|
|
var r Room
|
|
var status string
|
|
var isPublic, requireApproval int
|
|
var createdAt, updatedAt int64
|
|
var closedAt sql.NullInt64
|
|
var settingsJSON sql.NullString
|
|
var displayName, description sql.NullString
|
|
if err := rows.Scan(&r.Name, &displayName, &description, &r.Creator, &status, &isPublic, &requireApproval, &r.MaxMembers, &createdAt, &updatedAt, &closedAt, &settingsJSON, &r.Version); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
r.DisplayName = displayName.String
|
|
r.Description = description.String
|
|
r.Status = RoomStatus(status)
|
|
r.IsPublic = isPublic != 0
|
|
r.RequireApproval = requireApproval != 0
|
|
r.CreatedAt = time.Unix(createdAt, 0)
|
|
r.UpdatedAt = time.Unix(updatedAt, 0)
|
|
if closedAt.Valid {
|
|
t := time.Unix(closedAt.Int64, 0)
|
|
r.ClosedAt = &t
|
|
}
|
|
r.Settings = DefaultSettings()
|
|
if settingsJSON.Valid && settingsJSON.String != "" {
|
|
_ = json.Unmarshal([]byte(settingsJSON.String), r.Settings)
|
|
}
|
|
out = append(out, &r)
|
|
}
|
|
return out, total, rows.Err()
|
|
}
|
|
|
|
func (s *Store) ListRoomsFiltered(ctx context.Context, q RoomListQuery) ([]*RoomWithCount, int, error) {
|
|
rooms, total, err := s.QueryRooms(ctx, q)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var out []*RoomWithCount
|
|
for _, r := range rooms {
|
|
cnt, _ := s.CountMembers(ctx, r.Name)
|
|
out = append(out, &RoomWithCount{Room: *r, MemberCount: cnt})
|
|
}
|
|
return out, total, nil
|
|
}
|
|
|
|
func boolToInt(b bool) int {
|
|
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|