backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.
The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
656 lines
21 KiB
Go
656 lines
21 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"veza-backend-api/internal/models"
|
|
"veza-backend-api/internal/repositories"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// RoomService gère la logique métier pour les rooms
|
|
type RoomService struct {
|
|
roomRepo *repositories.RoomRepository
|
|
messageRepo *repositories.ChatMessageRepository
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewRoomService crée une nouvelle instance de RoomService
|
|
func NewRoomService(roomRepo *repositories.RoomRepository, messageRepo *repositories.ChatMessageRepository, logger *zap.Logger) *RoomService {
|
|
return &RoomService{
|
|
roomRepo: roomRepo,
|
|
messageRepo: messageRepo,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// CreateRoomRequest représente une requête de création de room
|
|
type CreateRoomRequest struct {
|
|
Name string `json:"name" binding:"required,min=1,max=255"`
|
|
Description *string `json:"description,omitempty"`
|
|
Type string `json:"type" binding:"required,oneof=public private direct collaborative"`
|
|
IsPrivate bool `json:"is_private"`
|
|
}
|
|
|
|
// RoomResponse représente une réponse de room pour l'API
|
|
// MIGRATION UUID: CreatedBy et Participants migrés vers UUID
|
|
type RoomResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Type string `json:"type"`
|
|
IsPrivate bool `json:"is_private"`
|
|
CreatedBy *uuid.UUID `json:"created_by"`
|
|
Participants []uuid.UUID `json:"participants"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// CreateRoom crée une nouvelle room
|
|
func (s *RoomService) CreateRoom(ctx context.Context, userID uuid.UUID, req CreateRoomRequest) (*RoomResponse, error) {
|
|
if req.Name == "" {
|
|
return nil, errors.New("room name is required")
|
|
}
|
|
|
|
// Créer la room
|
|
isPrivate := req.IsPrivate
|
|
if req.Type == "collaborative" {
|
|
isPrivate = true // v0.10.7 F483: collaborative rooms are invite-only
|
|
}
|
|
room := &models.Room{
|
|
Name: req.Name,
|
|
Description: "",
|
|
Type: req.Type,
|
|
IsPrivate: isPrivate,
|
|
CreatedBy: userID,
|
|
}
|
|
|
|
if req.Description != nil {
|
|
room.Description = *req.Description
|
|
}
|
|
|
|
if err := s.roomRepo.Create(ctx, room); err != nil {
|
|
s.logger.Error("failed to create room",
|
|
zap.Error(err),
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("room_name", req.Name))
|
|
return nil, fmt.Errorf("failed to create room: %w", err)
|
|
}
|
|
|
|
// Ajouter le créateur comme owner
|
|
member := &models.RoomMember{
|
|
RoomID: room.ID,
|
|
UserID: userID,
|
|
Role: "owner",
|
|
}
|
|
|
|
if err := s.roomRepo.AddMember(ctx, member); err != nil {
|
|
s.logger.Error("failed to add creator as room member",
|
|
zap.Error(err),
|
|
zap.String("room_id", room.ID.String()),
|
|
zap.String("user_id", userID.String()))
|
|
// Ne pas retourner d'erreur, la room est créée
|
|
}
|
|
|
|
s.logger.Info("room created successfully",
|
|
zap.String("room_id", room.ID.String()),
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("room_name", room.Name))
|
|
|
|
return &RoomResponse{
|
|
ID: room.ID,
|
|
Name: room.Name,
|
|
Description: room.Description,
|
|
Type: room.Type,
|
|
IsPrivate: room.IsPrivate,
|
|
CreatedBy: &room.CreatedBy, // Corrected: & to get pointer to uuid.UUID
|
|
Participants: []uuid.UUID{userID},
|
|
CreatedAt: room.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
UpdatedAt: room.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
}, nil
|
|
}
|
|
|
|
// GetUserRooms récupère toutes les rooms d'un utilisateur
|
|
func (s *RoomService) GetUserRooms(ctx context.Context, userID uuid.UUID) ([]*RoomResponse, error) {
|
|
rooms, err := s.roomRepo.GetByUserID(ctx, userID)
|
|
if err != nil {
|
|
s.logger.Error("failed to get user rooms",
|
|
zap.Error(err),
|
|
zap.String("user_id", userID.String()))
|
|
return nil, fmt.Errorf("failed to get user rooms: %w", err)
|
|
}
|
|
|
|
responses := make([]*RoomResponse, 0, len(rooms))
|
|
for _, room := range rooms {
|
|
// Récupérer les membres pour avoir la liste des participants
|
|
members, err := s.roomRepo.GetMembersByRoomID(ctx, room.ID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to get room members",
|
|
zap.Error(err),
|
|
zap.String("room_id", room.ID.String()))
|
|
members = []*models.RoomMember{}
|
|
}
|
|
|
|
participants := make([]uuid.UUID, 0, len(members))
|
|
for _, member := range members {
|
|
participants = append(participants, member.UserID)
|
|
}
|
|
|
|
responses = append(responses, &RoomResponse{
|
|
ID: room.ID,
|
|
Name: room.Name,
|
|
Description: room.Description,
|
|
Type: room.Type,
|
|
IsPrivate: room.IsPrivate,
|
|
CreatedBy: &room.CreatedBy, // Corrected: & to get pointer to uuid.UUID
|
|
Participants: participants,
|
|
CreatedAt: room.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
UpdatedAt: room.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
})
|
|
}
|
|
|
|
return responses, nil
|
|
}
|
|
|
|
// GetRoom récupère une room par son ID
|
|
func (s *RoomService) GetRoom(ctx context.Context, roomID uuid.UUID) (*RoomResponse, error) {
|
|
room, err := s.roomRepo.GetByID(ctx, roomID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrRoomNotFound
|
|
}
|
|
s.logger.Error("failed to get room",
|
|
zap.Error(err),
|
|
zap.String("room_id", roomID.String()))
|
|
return nil, fmt.Errorf("failed to get room: %w", err)
|
|
}
|
|
|
|
// Récupérer les membres
|
|
members, err := s.roomRepo.GetMembersByRoomID(ctx, roomID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to get room members",
|
|
zap.Error(err),
|
|
zap.String("room_id", roomID.String()))
|
|
members = []*models.RoomMember{}
|
|
}
|
|
|
|
participants := make([]uuid.UUID, 0, len(members))
|
|
for _, member := range members {
|
|
participants = append(participants, member.UserID)
|
|
}
|
|
|
|
return &RoomResponse{
|
|
ID: room.ID,
|
|
Name: room.Name,
|
|
Description: room.Description,
|
|
Type: room.Type,
|
|
IsPrivate: room.IsPrivate,
|
|
CreatedBy: &room.CreatedBy, // Corrected: & to get pointer to uuid.UUID
|
|
Participants: participants,
|
|
CreatedAt: room.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
UpdatedAt: room.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
}, nil
|
|
}
|
|
|
|
// IsRoomMember checks if a user is a member of a room.
|
|
// SECURITY(CRIT-001): Used to prevent unauthorized access to private conversations.
|
|
func (s *RoomService) IsRoomMember(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) (bool, error) {
|
|
members, err := s.roomRepo.GetMembersByRoomID(ctx, roomID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to check membership: %w", err)
|
|
}
|
|
for _, m := range members {
|
|
if m.UserID == userID {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// MaxCollaborativeMembers v0.10.7 F483
|
|
const MaxCollaborativeMembers = 10
|
|
|
|
// AddMember ajoute un membre à une room
|
|
func (s *RoomService) AddMember(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error {
|
|
// v0.10.7 F483: collaborative rooms max 10 participants
|
|
room, err := s.roomRepo.GetByID(ctx, roomID)
|
|
if err == nil && room.Type == "collaborative" {
|
|
count, _ := s.roomRepo.CountMembers(ctx, roomID)
|
|
if count >= MaxCollaborativeMembers {
|
|
return errors.New("collaborative room has reached maximum of 10 participants")
|
|
}
|
|
}
|
|
|
|
member := &models.RoomMember{
|
|
RoomID: roomID,
|
|
UserID: userID,
|
|
Role: "member",
|
|
}
|
|
|
|
if err := s.roomRepo.AddMember(ctx, member); err != nil {
|
|
s.logger.Error("failed to add member to room",
|
|
zap.Error(err),
|
|
zap.String("room_id", roomID.String()),
|
|
zap.String("user_id", userID.String()))
|
|
return fmt.Errorf("failed to add member: %w", err)
|
|
}
|
|
|
|
s.logger.Info("member added to room",
|
|
zap.String("room_id", roomID.String()),
|
|
zap.String("user_id", userID.String()))
|
|
|
|
return nil
|
|
}
|
|
|
|
// KickMember removes a member from a room. Only owner or admin can kick. (v0.9.7)
|
|
func (s *RoomService) KickMember(ctx context.Context, roomID uuid.UUID, targetUserID uuid.UUID, requestUserID uuid.UUID) error {
|
|
role, err := s.roomRepo.GetMemberRole(ctx, roomID, requestUserID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get requester role: %w", err)
|
|
}
|
|
if role == "" {
|
|
return ErrRoomNotFound
|
|
}
|
|
if role != "owner" && role != "admin" {
|
|
return errors.New("forbidden: only owner or admin can remove members")
|
|
}
|
|
return s.RemoveMember(ctx, roomID, targetUserID)
|
|
}
|
|
|
|
// CreateInvitation creates a room invitation (link-based, expires 7 days). Inviter must be owner/admin.
|
|
func (s *RoomService) CreateInvitation(ctx context.Context, roomID uuid.UUID, inviterID uuid.UUID) (*RoomInvitationResponse, error) {
|
|
role, err := s.roomRepo.GetMemberRole(ctx, roomID, inviterID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get inviter role: %w", err)
|
|
}
|
|
if role == "" {
|
|
return nil, ErrRoomNotFound
|
|
}
|
|
if role != "owner" && role != "admin" {
|
|
return nil, errors.New("forbidden: only owner or admin can create invitations")
|
|
}
|
|
inv := &models.RoomInvitation{
|
|
RoomID: roomID,
|
|
InviterID: inviterID,
|
|
Status: "pending",
|
|
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
|
}
|
|
if err := s.roomRepo.CreateRoomInvitation(ctx, inv); err != nil {
|
|
return nil, fmt.Errorf("failed to create invitation: %w", err)
|
|
}
|
|
return &RoomInvitationResponse{
|
|
Token: inv.Token.String(),
|
|
InviteURL: "/chat/join/" + inv.Token.String(),
|
|
ExpiresAt: inv.ExpiresAt,
|
|
}, nil
|
|
}
|
|
|
|
// RoomInvitationResponse for CreateInvitation
|
|
type RoomInvitationResponse struct {
|
|
Token string `json:"token"`
|
|
InviteURL string `json:"invite_url"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
// JoinByToken joins a room using an invitation token. Returns room ID.
|
|
func (s *RoomService) JoinByToken(ctx context.Context, token uuid.UUID, userID uuid.UUID) (uuid.UUID, error) {
|
|
inv, err := s.roomRepo.GetRoomInvitationByToken(ctx, token)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return uuid.Nil, errors.New("invitation not found or expired")
|
|
}
|
|
return uuid.Nil, fmt.Errorf("failed to get invitation: %w", err)
|
|
}
|
|
// Add user as member
|
|
if err := s.AddMember(ctx, inv.RoomID, userID); err != nil {
|
|
return uuid.Nil, fmt.Errorf("failed to join room: %w", err)
|
|
}
|
|
_ = s.roomRepo.UpdateRoomInvitationStatus(ctx, inv.ID, "accepted")
|
|
return inv.RoomID, nil
|
|
}
|
|
|
|
// RemoveMember retire un membre d'une room
|
|
// BE-API-011: Implement conversation participants endpoints
|
|
func (s *RoomService) RemoveMember(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error {
|
|
// Vérifier que la room existe
|
|
_, err := s.roomRepo.GetByID(ctx, roomID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrRoomNotFound
|
|
}
|
|
return fmt.Errorf("failed to get room: %w", err)
|
|
}
|
|
|
|
// Retirer le membre
|
|
if err := s.roomRepo.RemoveMember(ctx, roomID, userID); err != nil {
|
|
s.logger.Error("failed to remove member from room",
|
|
zap.Error(err),
|
|
zap.String("room_id", roomID.String()),
|
|
zap.String("user_id", userID.String()))
|
|
return fmt.Errorf("failed to remove member: %w", err)
|
|
}
|
|
|
|
s.logger.Info("member removed from room",
|
|
zap.String("room_id", roomID.String()),
|
|
zap.String("user_id", userID.String()))
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateRoomRequest représente une requête de mise à jour de room
|
|
// BE-API-012: Implement conversation update endpoint
|
|
type UpdateRoomRequest struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
}
|
|
|
|
// UpdateRoom met à jour une room (nom et/ou description)
|
|
// BE-API-012: Implement conversation update endpoint
|
|
// Seul le créateur de la room ou un admin peut mettre à jour la room
|
|
func (s *RoomService) UpdateRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID, req UpdateRoomRequest) (*RoomResponse, error) {
|
|
// Vérifier que la room existe
|
|
room, err := s.roomRepo.GetByID(ctx, roomID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrRoomNotFound
|
|
}
|
|
return nil, fmt.Errorf("failed to get room: %w", err)
|
|
}
|
|
|
|
// Vérifier que l'utilisateur est le créateur de la room
|
|
if room.CreatedBy != userID {
|
|
return nil, fmt.Errorf("forbidden: only room creator can update the room")
|
|
}
|
|
|
|
// Mettre à jour les champs fournis
|
|
if req.Name != nil {
|
|
room.Name = *req.Name
|
|
}
|
|
if req.Description != nil {
|
|
room.Description = *req.Description
|
|
}
|
|
|
|
// Sauvegarder les modifications
|
|
if err := s.roomRepo.Update(ctx, room); err != nil {
|
|
s.logger.Error("failed to update room",
|
|
zap.Error(err),
|
|
zap.String("room_id", roomID.String()),
|
|
zap.String("user_id", userID.String()))
|
|
return nil, fmt.Errorf("failed to update room: %w", err)
|
|
}
|
|
|
|
// Récupérer les membres pour la réponse
|
|
members, err := s.roomRepo.GetMembersByRoomID(ctx, roomID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to get room members",
|
|
zap.Error(err),
|
|
zap.String("room_id", roomID.String()))
|
|
members = []*models.RoomMember{}
|
|
}
|
|
|
|
participants := make([]uuid.UUID, 0, len(members))
|
|
for _, member := range members {
|
|
participants = append(participants, member.UserID)
|
|
}
|
|
|
|
s.logger.Info("room updated successfully",
|
|
zap.String("room_id", roomID.String()),
|
|
zap.String("user_id", userID.String()))
|
|
|
|
return &RoomResponse{
|
|
ID: room.ID,
|
|
Name: room.Name,
|
|
Description: room.Description,
|
|
Type: room.Type,
|
|
IsPrivate: room.IsPrivate,
|
|
CreatedBy: &room.CreatedBy,
|
|
Participants: participants,
|
|
CreatedAt: room.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
UpdatedAt: room.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
}, nil
|
|
}
|
|
|
|
// MessageAttachmentResponse for history (v0.9.7)
|
|
type MessageAttachmentResponse struct {
|
|
FileURL string `json:"file_url"`
|
|
FileName string `json:"file_name"`
|
|
FileType string `json:"file_type"`
|
|
FileSize int64 `json:"file_size,omitempty"`
|
|
}
|
|
|
|
// ChatMessageResponse pour la réponse d'historique (v0.9.7: SenderUsername, Attachments)
|
|
type ChatMessageResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ConversationID uuid.UUID `json:"conversation_id"`
|
|
SenderID uuid.UUID `json:"sender_id"`
|
|
SenderUsername string `json:"sender_username,omitempty"`
|
|
Content string `json:"content"`
|
|
MessageType string `json:"message_type"`
|
|
ParentMessageID *uuid.UUID `json:"parent_message_id,omitempty"`
|
|
Attachments []MessageAttachmentResponse `json:"attachments,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// parseAttachmentsFromMetadata extracts attachments from message metadata JSONB
|
|
func parseAttachmentsFromMetadata(metadata []byte) []MessageAttachmentResponse {
|
|
if len(metadata) == 0 {
|
|
return nil
|
|
}
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal(metadata, &m); err != nil {
|
|
return nil
|
|
}
|
|
raw, ok := m["attachments"]
|
|
if !ok || raw == nil {
|
|
return nil
|
|
}
|
|
arr, ok := raw.([]interface{})
|
|
if !ok || len(arr) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]MessageAttachmentResponse, 0, len(arr))
|
|
for _, v := range arr {
|
|
obj, ok := v.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
a := MessageAttachmentResponse{}
|
|
if s, ok := obj["file_url"].(string); ok {
|
|
a.FileURL = s
|
|
}
|
|
if s, ok := obj["file_name"].(string); ok {
|
|
a.FileName = s
|
|
}
|
|
if s, ok := obj["file_type"].(string); ok {
|
|
a.FileType = s
|
|
}
|
|
if n, ok := obj["file_size"].(float64); ok {
|
|
a.FileSize = int64(n)
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetRoomHistory récupère l'historique des messages d'une room
|
|
func (s *RoomService) GetRoomHistory(ctx context.Context, roomID uuid.UUID, limit, offset int) ([]ChatMessageResponse, error) {
|
|
messages, err := s.messageRepo.GetConversationMessages(ctx, roomID, limit, offset)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) || err.Error() == "conversation not found" {
|
|
// Check if room exists first? Assuming Repo handles it or we could use GetRoom logic
|
|
// If messageRepo returns error on room not found
|
|
return nil, ErrRoomNotFound
|
|
}
|
|
s.logger.Error("failed to get room history",
|
|
zap.Error(err),
|
|
zap.String("room_id", roomID.String()))
|
|
return nil, fmt.Errorf("failed to get room history: %w", err)
|
|
}
|
|
|
|
responses := make([]ChatMessageResponse, len(messages))
|
|
for i, msg := range messages {
|
|
senderUsername := ""
|
|
if msg.Sender != nil {
|
|
senderUsername = msg.Sender.Username
|
|
}
|
|
responses[i] = ChatMessageResponse{
|
|
ID: msg.ID,
|
|
ConversationID: msg.ConversationID,
|
|
SenderID: msg.SenderID,
|
|
SenderUsername: senderUsername,
|
|
Content: msg.Content,
|
|
MessageType: msg.MessageType,
|
|
ParentMessageID: msg.ReplyToID,
|
|
Attachments: parseAttachmentsFromMetadata(msg.Metadata),
|
|
CreatedAt: msg.CreatedAt,
|
|
}
|
|
}
|
|
return responses, nil
|
|
}
|
|
|
|
// RoomHistoryWithCursorResult holds messages and next cursor for cursor-based pagination (v0.931).
|
|
type RoomHistoryWithCursorResult struct {
|
|
Messages []ChatMessageResponse
|
|
NextCursor string
|
|
}
|
|
|
|
// GetRoomHistoryWithCursor uses keyset pagination on (created_at, id) for consistent performance.
|
|
func (s *RoomService) GetRoomHistoryWithCursor(ctx context.Context, roomID uuid.UUID, limit int, cursor string) (*RoomHistoryWithCursorResult, error) {
|
|
result, err := s.messageRepo.GetConversationMessagesWithCursor(ctx, roomID, limit, cursor)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) || err.Error() == "conversation not found" {
|
|
return nil, ErrRoomNotFound
|
|
}
|
|
s.logger.Error("failed to get room history with cursor",
|
|
zap.Error(err),
|
|
zap.String("room_id", roomID.String()))
|
|
return nil, fmt.Errorf("failed to get room history: %w", err)
|
|
}
|
|
responses := make([]ChatMessageResponse, len(result.Messages))
|
|
for i, msg := range result.Messages {
|
|
senderUsername := ""
|
|
if msg.Sender != nil {
|
|
senderUsername = msg.Sender.Username
|
|
}
|
|
responses[i] = ChatMessageResponse{
|
|
ID: msg.ID,
|
|
ConversationID: msg.ConversationID,
|
|
SenderID: msg.SenderID,
|
|
SenderUsername: senderUsername,
|
|
Content: msg.Content,
|
|
MessageType: msg.MessageType,
|
|
ParentMessageID: msg.ReplyToID,
|
|
Attachments: parseAttachmentsFromMetadata(msg.Metadata),
|
|
CreatedAt: msg.CreatedAt,
|
|
}
|
|
}
|
|
return &RoomHistoryWithCursorResult{Messages: responses, NextCursor: result.NextCursor}, nil
|
|
}
|
|
|
|
// DeleteRoom supprime une room (soft delete)
|
|
// BE-API-010: Implement conversation delete endpoint
|
|
// Seul le créateur de la room ou un admin peut supprimer la room
|
|
func (s *RoomService) DeleteRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error {
|
|
// Vérifier que la room existe
|
|
room, err := s.roomRepo.GetByID(ctx, roomID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrRoomNotFound
|
|
}
|
|
return fmt.Errorf("failed to get room: %w", err)
|
|
}
|
|
|
|
// Vérifier que l'utilisateur est le créateur de la room
|
|
if room.CreatedBy != userID {
|
|
return fmt.Errorf("forbidden: only room creator can delete the room")
|
|
}
|
|
|
|
// Supprimer la room (soft delete via GORM)
|
|
if err := s.roomRepo.Delete(ctx, roomID); err != nil {
|
|
s.logger.Error("failed to delete room",
|
|
zap.Error(err),
|
|
zap.String("room_id", roomID.String()),
|
|
zap.String("user_id", userID.String()))
|
|
return fmt.Errorf("failed to delete room: %w", err)
|
|
}
|
|
|
|
s.logger.Info("room deleted successfully",
|
|
zap.String("room_id", roomID.String()),
|
|
zap.String("user_id", userID.String()))
|
|
|
|
return nil
|
|
}
|
|
|
|
// RoomMemberResponse for members list (v0.9.6 @mention, v0.9.7 roles)
|
|
type RoomMemberResponse struct {
|
|
UserID uuid.UUID `json:"user_id"`
|
|
Username string `json:"username"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// RoomMembersResponse for GET members (v0.9.7) includes my_role for UI (show kick button)
|
|
type RoomMembersResponse struct {
|
|
Members []RoomMemberResponse `json:"members"`
|
|
MyRole string `json:"my_role"` // owner, admin, member
|
|
}
|
|
|
|
// UpdateMemberRoleRequest for PATCH members (v0.9.7)
|
|
type UpdateMemberRoleRequest struct {
|
|
Role string `json:"role" binding:"required,oneof=admin moderator member"`
|
|
}
|
|
|
|
// UpdateMemberRole updates a member's role. Only owner can promote to admin. Owner cannot demote themselves.
|
|
func (s *RoomService) UpdateMemberRole(ctx context.Context, roomID, targetUserID, requestUserID uuid.UUID, newRole string) error {
|
|
requesterRole, err := s.roomRepo.GetMemberRole(ctx, roomID, requestUserID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get requester role: %w", err)
|
|
}
|
|
if requesterRole == "" {
|
|
return ErrRoomNotFound
|
|
}
|
|
if requesterRole != "owner" {
|
|
return errors.New("forbidden: only owner can change member roles")
|
|
}
|
|
targetRole, err := s.roomRepo.GetMemberRole(ctx, roomID, targetUserID)
|
|
if err != nil || targetRole == "" {
|
|
return ErrRoomNotFound
|
|
}
|
|
if targetRole == "owner" {
|
|
return errors.New("forbidden: cannot change owner role")
|
|
}
|
|
if targetUserID == requestUserID {
|
|
return errors.New("forbidden: owner cannot change their own role")
|
|
}
|
|
if newRole == "owner" {
|
|
return errors.New("forbidden: cannot promote to owner via this endpoint")
|
|
}
|
|
return s.roomRepo.UpdateMemberRole(ctx, roomID, targetUserID, newRole)
|
|
}
|
|
|
|
// GetRoomMembers returns members of a room. Requesting user must be a member.
|
|
func (s *RoomService) GetRoomMembers(ctx context.Context, roomID uuid.UUID, requestUserID uuid.UUID) (*RoomMembersResponse, error) {
|
|
members, err := s.roomRepo.GetMembersByRoomID(ctx, roomID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get room members: %w", err)
|
|
}
|
|
var myRole string
|
|
out := make([]RoomMemberResponse, 0, len(members))
|
|
for _, m := range members {
|
|
username := m.User.Username
|
|
out = append(out, RoomMemberResponse{UserID: m.UserID, Username: username, Role: m.Role})
|
|
if m.UserID == requestUserID {
|
|
myRole = m.Role
|
|
}
|
|
}
|
|
if myRole == "" {
|
|
return nil, ErrRoomNotFound
|
|
}
|
|
return &RoomMembersResponse{Members: out, MyRole: myRole}, nil
|
|
}
|