veza/veza-backend-api/internal/services/room_service.go
senke a1000ce7fb style(backend): gofmt -w on 85 files (whitespace only)
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.
2026-04-14 12:22:14 +02:00

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
}