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(` + +
++ %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. +
+