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 }