diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a4d3557..b49c854 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -68,6 +68,7 @@ func main() { chunkRepo := repositories.NewAudioChunkRepository(database.GetDB()) transcriptRepo := repositories.NewTranscriptRepository(database.GetDB()) groupRepo := repositories.NewGroupRepository(database.GetDB()) + invitationRepo := repositories.NewInvitationRepository(database.GetDB()) // Initialize services meetingService := services.NewMeetingService(meetingRepo) @@ -76,6 +77,7 @@ func main() { livekitService := services.NewLiveKitService(cfg) openRouterService := services.NewOpenRouterService(cfg) emailService := services.NewEmailService(cfg) + invitationService := services.NewInvitationService(invitationRepo, groupRepo, userService, emailService, cfg.Server.FrontendURL) // Dependency chain: SummarizationService <- NormalizationService <- TranscriptionService <- SummarizerService summarizationService := services.NewSummarizationService(sessionRepo, userService, openRouterService, emailService, cfg) @@ -88,6 +90,7 @@ func main() { authHandler := handlers.NewAuthHandler(userService, cfg) meetingHandler := handlers.NewMeetingHandler(meetingService, cfg) groupHandler := handlers.NewGroupHandler(groupService) + invitationHandler := handlers.NewInvitationHandler(invitationService) livekitHandler := handlers.NewLiveKitHandler(livekitService, meetingService, userService, summarizerService, cfg) lobbyHandler := handlers.NewLobbyHandler(livekitService, meetingService, userService, cfg) lobbyWSHandler := handlers.NewLobbyWSHandler(livekitService, meetingService, userService, cfg) @@ -105,7 +108,7 @@ func main() { go summarizationWorker.Start() // Setup routes - routes.SetupRoutes(app, userHandler, authHandler, meetingHandler, livekitHandler, lobbyHandler, lobbyWSHandler, summarizerHandler, groupHandler, cfg) + routes.SetupRoutes(app, userHandler, authHandler, meetingHandler, livekitHandler, lobbyHandler, lobbyWSHandler, summarizerHandler, groupHandler, invitationHandler, cfg) // Health check route app.Get("/api/v1/health", func(c *fiber.Ctx) error { diff --git a/backend/internal/handlers/dto/invitation.go b/backend/internal/handlers/dto/invitation.go new file mode 100644 index 0000000..1782031 --- /dev/null +++ b/backend/internal/handlers/dto/invitation.go @@ -0,0 +1,53 @@ +package dto + +import ( + "mini-meeting/internal/models" + "time" +) + +// --- Request types --- + +type SendInvitationRequest struct { + Email string `json:"email"` +} + +type AcceptInvitationRequest struct { + Token string `json:"token"` +} + +// --- Response types --- + +type InvitationResponse struct { + ID uint `json:"id"` + GroupID uint `json:"group_id"` + InvitedBy uint `json:"invited_by"` + Email string `json:"email"` + Status models.InvitationStatus `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + Group *GroupResponse `json:"group,omitempty"` + Inviter *models.User `json:"inviter,omitempty"` +} + +// --- Converters --- + +func ToInvitationResponse(inv *models.GroupInvitation) InvitationResponse { + resp := InvitationResponse{ + ID: inv.ID, + GroupID: inv.GroupID, + InvitedBy: inv.InvitedBy, + Email: inv.Email, + Status: inv.Status, + ExpiresAt: inv.ExpiresAt, + CreatedAt: inv.CreatedAt, + } + if inv.Group.ID != 0 { + g := ToGroupResponse(&inv.Group) + resp.Group = &g + } + if inv.Inviter.ID != 0 { + u := inv.Inviter + resp.Inviter = &u + } + return resp +} diff --git a/backend/internal/handlers/invitation_handler.go b/backend/internal/handlers/invitation_handler.go new file mode 100644 index 0000000..48db9a7 --- /dev/null +++ b/backend/internal/handlers/invitation_handler.go @@ -0,0 +1,167 @@ +package handlers + +import ( + "mini-meeting/internal/handlers/dto" + "mini-meeting/internal/services" + + "github.com/gofiber/fiber/v2" +) + +type InvitationHandler struct { + service *services.InvitationService +} + +func NewInvitationHandler(service *services.InvitationService) *InvitationHandler { + return &InvitationHandler{service: service} +} + +// SendInvitation creates and emails a new group invitation. +// POST /api/v1/groups/:id/invitations +func (h *InvitationHandler) SendInvitation(c *fiber.Ctx) error { + callerID, ok := c.Locals("userID").(uint) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + groupID, err := parseID(c.Params("id")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid group ID"}) + } + + var req dto.SendInvitationRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + if req.Email == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email is required"}) + } + + inv, err := h.service.SendInvitation(groupID, callerID, req.Email) + if err != nil { + return invitationError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "message": "Invitation sent successfully", + "data": dto.ToInvitationResponse(inv), + }) +} + +// ListInvitations returns all invitations for a group (admin/moderator only). +// GET /api/v1/groups/:id/invitations +func (h *InvitationHandler) ListInvitations(c *fiber.Ctx) error { + callerID, ok := c.Locals("userID").(uint) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + groupID, err := parseID(c.Params("id")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid group ID"}) + } + + invitations, err := h.service.ListInvitations(groupID, callerID) + if err != nil { + return invitationError(c, err) + } + + responses := make([]dto.InvitationResponse, len(invitations)) + for i := range invitations { + responses[i] = dto.ToInvitationResponse(&invitations[i]) + } + + return c.JSON(fiber.Map{"data": responses}) +} + +// CancelInvitation sets an invitation to expired (admin/moderator only). +// DELETE /api/v1/groups/:id/invitations/:invId +func (h *InvitationHandler) CancelInvitation(c *fiber.Ctx) error { + callerID, ok := c.Locals("userID").(uint) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + groupID, err := parseID(c.Params("id")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid group ID"}) + } + + invID, err := parseID(c.Params("invId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid invitation ID"}) + } + + if err := h.service.CancelInvitation(groupID, invID, callerID); err != nil { + return invitationError(c, err) + } + + return c.JSON(fiber.Map{"message": "Invitation cancelled successfully"}) +} + +// GetInvitationInfo returns public info about an invitation by token. +// GET /api/v1/invitations/info?token= +func (h *InvitationHandler) GetInvitationInfo(c *fiber.Ctx) error { + token := c.Query("token") + if token == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Token is required"}) + } + + inv, err := h.service.GetInvitationInfo(token) + if err != nil { + return invitationError(c, err) + } + + return c.JSON(fiber.Map{"data": dto.ToInvitationResponse(inv)}) +} + +// AcceptInvitation adds the authenticated user to the group referenced by the token. +// POST /api/v1/invitations/accept +func (h *InvitationHandler) AcceptInvitation(c *fiber.Ctx) error { + callerID, ok := c.Locals("userID").(uint) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + callerEmail, ok := c.Locals("email").(string) + if !ok || callerEmail == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + var req dto.AcceptInvitationRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + if req.Token == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Token is required"}) + } + + if err := h.service.AcceptInvitation(req.Token, callerID, callerEmail); err != nil { + return invitationError(c, err) + } + + return c.JSON(fiber.Map{"message": "Invitation accepted successfully"}) +} + +// --- helpers --- + +func invitationError(c *fiber.Ctx, err error) error { + switch err { + case services.ErrInvitationNotFound: + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + case services.ErrInvitationExpired: + return c.Status(fiber.StatusGone).JSON(fiber.Map{"error": err.Error()}) + case services.ErrInvitationEmailMatch: + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) + case services.ErrDuplicateInvitation: + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) + case services.ErrGroupNotFound: + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + case services.ErrNotGroupMember: + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) + case services.ErrNotGroupAdminOrMod: + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) + case services.ErrAlreadyMember: + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) + default: + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } +} diff --git a/backend/internal/models/group_invitation.go b/backend/internal/models/group_invitation.go new file mode 100644 index 0000000..cdd7c8c --- /dev/null +++ b/backend/internal/models/group_invitation.go @@ -0,0 +1,27 @@ +package models + +import "time" + +type InvitationStatus string + +const ( + InvitationStatusPending InvitationStatus = "pending" + InvitationStatusAccepted InvitationStatus = "accepted" + InvitationStatusExpired InvitationStatus = "expired" +) + +type GroupInvitation struct { + ID uint `gorm:"primaryKey" json:"id"` + GroupID uint `gorm:"not null;index" json:"group_id"` + InvitedBy uint `gorm:"not null" json:"invited_by"` + Email string `gorm:"not null;size:255" json:"email"` + Token string `gorm:"unique;not null;size:64" json:"-"` + Status InvitationStatus `gorm:"not null;default:'pending'" json:"status"` + ExpiresAt time.Time `json:"expires_at"` + AcceptedAt *time.Time `json:"accepted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + + // Relations + Group Group `gorm:"foreignKey:GroupID" json:"group,omitempty"` + Inviter User `gorm:"foreignKey:InvitedBy" json:"inviter,omitempty"` +} diff --git a/backend/internal/repositories/invitation_repository.go b/backend/internal/repositories/invitation_repository.go new file mode 100644 index 0000000..ab3dd07 --- /dev/null +++ b/backend/internal/repositories/invitation_repository.go @@ -0,0 +1,57 @@ +package repositories + +import ( + "mini-meeting/internal/models" + + "gorm.io/gorm" +) + +type InvitationRepository struct { + db *gorm.DB +} + +func NewInvitationRepository(db *gorm.DB) *InvitationRepository { + return &InvitationRepository{db: db} +} + +func (r *InvitationRepository) CreateInvitation(inv *models.GroupInvitation) error { + return r.db.Create(inv).Error +} + +func (r *InvitationRepository) FindByToken(token string) (*models.GroupInvitation, error) { + var inv models.GroupInvitation + err := r.db. + Preload("Group"). + Preload("Inviter"). + Where("token = ?", token). + First(&inv).Error + if err != nil { + return nil, err + } + return &inv, nil +} + +func (r *InvitationRepository) FindPendingByGroupAndEmail(groupID uint, email string) (*models.GroupInvitation, error) { + var inv models.GroupInvitation + err := r.db. + Where("group_id = ? AND email = ? AND status = ?", groupID, email, models.InvitationStatusPending). + First(&inv).Error + if err != nil { + return nil, err + } + return &inv, nil +} + +func (r *InvitationRepository) UpdateStatus(inv *models.GroupInvitation) error { + return r.db.Save(inv).Error +} + +func (r *InvitationRepository) FindByGroupID(groupID uint) ([]models.GroupInvitation, error) { + var invitations []models.GroupInvitation + err := r.db. + Preload("Inviter"). + Where("group_id = ?", groupID). + Order("created_at DESC"). + Find(&invitations).Error + return invitations, err +} diff --git a/backend/internal/routes/invitation.go b/backend/internal/routes/invitation.go new file mode 100644 index 0000000..a4a9ae5 --- /dev/null +++ b/backend/internal/routes/invitation.go @@ -0,0 +1,22 @@ +package routes + +import ( + "mini-meeting/internal/config" + "mini-meeting/internal/handlers" + "mini-meeting/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func setupInvitationRoutes(api fiber.Router, invitationHandler *handlers.InvitationHandler, cfg *config.Config) { + // Group-scoped invitation routes (admin/moderator only) + groups := api.Group("/groups", middleware.AuthMiddleware(cfg)) + groups.Post("/:id/invitations", invitationHandler.SendInvitation) + groups.Get("/:id/invitations", invitationHandler.ListInvitations) + groups.Delete("/:id/invitations/:invId", invitationHandler.CancelInvitation) + + // Top-level invitation routes + invitations := api.Group("/invitations") + invitations.Get("/info", invitationHandler.GetInvitationInfo) + invitations.Post("/accept", middleware.AuthMiddleware(cfg), invitationHandler.AcceptInvitation) +} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index b4a865b..98ae917 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -17,6 +17,7 @@ func SetupRoutes( lobbyWSHandler *handlers.LobbyWSHandler, summarizerHandler *handlers.SummarizerHandler, groupHandler *handlers.GroupHandler, + invitationHandler *handlers.InvitationHandler, cfg *config.Config, ) { api := app.Group("/api/v1") @@ -27,4 +28,5 @@ func SetupRoutes( setupLiveKitRoutes(api, livekitHandler, cfg) setupLobbyRoutes(app, api, lobbyHandler, lobbyWSHandler) setupGroupRoutes(api, groupHandler, cfg) + setupInvitationRoutes(api, invitationHandler, cfg) } diff --git a/backend/internal/services/email_service.go b/backend/internal/services/email_service.go index f5fcec9..ccc19a0 100644 --- a/backend/internal/services/email_service.go +++ b/backend/internal/services/email_service.go @@ -117,3 +117,82 @@ func (s *EmailService) SendSessionReadyEmail(toEmail, toName string, sessionID u fmt.Printf("EmailService: session-ready email sent to %s for session %d\n", toEmail, sessionID) return nil } + +// SendGroupInvitationEmail sends a group invitation email with a CTA button linking to the accept page. +func (s *EmailService) SendGroupInvitationEmail(toEmail, inviterName, groupName, invitationURL string) error { + if s.cfg.APIKey == "" { + return fmt.Errorf("Brevo API key is not configured") + } + + htmlBody := fmt.Sprintf(` + + +
+

You've been invited to join a group

+

+ %s has invited you to join the group %s on Mini Meeting. +

+

+ Click the button below to accept the invitation. This invitation expires in 7 days. +

+ + Accept Invitation + +

+ Or copy this link: %s +

+

+ If you did not expect this invitation, you can safely ignore this email. +

+
+ +`, inviterName, groupName, invitationURL, invitationURL) + + textBody := fmt.Sprintf( + "%s has invited you to join the group \"%s\" on Mini Meeting.\n\nAccept the invitation here (expires in 7 days):\n%s\n\nIf you did not expect this, please ignore this email.\n\nMini Meeting", + inviterName, groupName, invitationURL, + ) + + payload := brevoEmailRequest{ + Sender: brevoContact{ + Email: s.cfg.SenderEmail, + Name: s.cfg.SenderName, + }, + To: []brevoContact{ + {Email: toEmail}, + }, + Subject: fmt.Sprintf("%s invited you to join \"%s\"", inviterName, groupName), + HTMLContent: htmlBody, + TextContent: textBody, + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal email payload: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, brevoAPIURL, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("api-key", s.cfg.APIKey) + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request to Brevo: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Brevo API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + fmt.Printf("EmailService: group invitation email sent to %s for group %s\n", toEmail, groupName) + return nil +} diff --git a/backend/internal/services/invitation_service.go b/backend/internal/services/invitation_service.go new file mode 100644 index 0000000..8415d9a --- /dev/null +++ b/backend/internal/services/invitation_service.go @@ -0,0 +1,235 @@ +package services + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "mini-meeting/internal/models" + "mini-meeting/internal/repositories" + "time" + + "gorm.io/gorm" +) + +var ( + ErrInvitationNotFound = errors.New("invitation not found") + ErrInvitationExpired = errors.New("invitation has expired") + ErrInvitationEmailMatch = errors.New("this invitation was sent to a different email address") + ErrDuplicateInvitation = errors.New("a pending invitation already exists for this email in the group") + ErrNotGroupAdminOrMod = errors.New("only group admins or moderators can perform this action") +) + +type InvitationService struct { + repo *repositories.InvitationRepository + groupRepo *repositories.GroupRepository + userService *UserService + emailService *EmailService + frontendURL string +} + +func NewInvitationService( + repo *repositories.InvitationRepository, + groupRepo *repositories.GroupRepository, + userService *UserService, + emailService *EmailService, + frontendURL string, +) *InvitationService { + return &InvitationService{ + repo: repo, + groupRepo: groupRepo, + userService: userService, + emailService: emailService, + frontendURL: frontendURL, + } +} + +func isAdminOrModerator(role models.GroupRole) bool { + return role == models.GroupRoleAdmin || role == models.GroupRoleModerator +} + +// SendInvitation validates the caller's role, checks for duplicates, generates a token, and sends an email. +func (s *InvitationService) SendInvitation(groupID, callerID uint, toEmail string) (*models.GroupInvitation, error) { + group, err := s.groupRepo.FindGroupByID(groupID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGroupNotFound + } + return nil, err + } + + caller, err := s.groupRepo.FindMember(groupID, callerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotGroupMember + } + return nil, err + } + if !isAdminOrModerator(caller.Role) { + return nil, ErrNotGroupAdminOrMod + } + + _, err = s.repo.FindPendingByGroupAndEmail(groupID, toEmail) + if err == nil { + return nil, ErrDuplicateInvitation + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, fmt.Errorf("failed to generate invitation token: %w", err) + } + token := hex.EncodeToString(tokenBytes) + + inv := &models.GroupInvitation{ + GroupID: groupID, + InvitedBy: callerID, + Email: toEmail, + Token: token, + Status: models.InvitationStatusPending, + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), + } + if err := s.repo.CreateInvitation(inv); err != nil { + return nil, err + } + + inviter, err := s.userService.GetUserByID(callerID) + if err != nil { + inviter = &models.User{Name: "A team member"} + } + + invitationURL := fmt.Sprintf("%s/groups/invite?token=%s", s.frontendURL, token) + go s.emailService.SendGroupInvitationEmail(toEmail, inviter.Name, group.Name, invitationURL) + + return inv, nil +} + +// AcceptInvitation validates the token, checks expiry, enforces email match, and adds the user to the group. +func (s *InvitationService) AcceptInvitation(token string, callerID uint, callerEmail string) error { + inv, err := s.repo.FindByToken(token) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrInvitationNotFound + } + return err + } + + if inv.Status == models.InvitationStatusExpired || time.Now().After(inv.ExpiresAt) { + if inv.Status != models.InvitationStatusExpired { + inv.Status = models.InvitationStatusExpired + _ = s.repo.UpdateStatus(inv) + } + return ErrInvitationExpired + } + + if inv.Status != models.InvitationStatusPending { + return ErrInvitationNotFound + } + + if inv.Email != callerEmail { + return ErrInvitationEmailMatch + } + + isMember, err := s.groupRepo.IsMember(inv.GroupID, callerID) + if err != nil { + return err + } + if isMember { + return ErrAlreadyMember + } + + member := &models.GroupMember{ + GroupID: inv.GroupID, + UserID: callerID, + Role: models.GroupRoleMember, + JoinedAt: time.Now(), + } + if err := s.groupRepo.AddMember(member); err != nil { + return err + } + + now := time.Now() + inv.Status = models.InvitationStatusAccepted + inv.AcceptedAt = &now + return s.repo.UpdateStatus(inv) +} + +// ListInvitations returns all invitations for a group (admins and moderators only). +func (s *InvitationService) ListInvitations(groupID, callerID uint) ([]models.GroupInvitation, error) { + _, err := s.groupRepo.FindGroupByID(groupID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGroupNotFound + } + return nil, err + } + + caller, err := s.groupRepo.FindMember(groupID, callerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotGroupMember + } + return nil, err + } + if !isAdminOrModerator(caller.Role) { + return nil, ErrNotGroupAdminOrMod + } + + return s.repo.FindByGroupID(groupID) +} + +// CancelInvitation sets an invitation's status to expired (admins and moderators only). +func (s *InvitationService) CancelInvitation(groupID, invitationID, callerID uint) error { + caller, err := s.groupRepo.FindMember(groupID, callerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotGroupMember + } + return err + } + if !isAdminOrModerator(caller.Role) { + return ErrNotGroupAdminOrMod + } + + invitations, err := s.repo.FindByGroupID(groupID) + if err != nil { + return err + } + + var target *models.GroupInvitation + for i := range invitations { + if invitations[i].ID == invitationID { + target = &invitations[i] + break + } + } + if target == nil { + return ErrInvitationNotFound + } + + target.Status = models.InvitationStatusExpired + return s.repo.UpdateStatus(target) +} + +// GetInvitationInfo returns public invitation info for the accept page (no auth required). +func (s *InvitationService) GetInvitationInfo(token string) (*models.GroupInvitation, error) { + inv, err := s.repo.FindByToken(token) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrInvitationNotFound + } + return nil, err + } + + if inv.Status == models.InvitationStatusExpired || time.Now().After(inv.ExpiresAt) { + return nil, ErrInvitationExpired + } + + if inv.Status != models.InvitationStatusPending { + return nil, ErrInvitationNotFound + } + + return inv, nil +}