Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
53 changes: 53 additions & 0 deletions backend/internal/handlers/dto/invitation.go
Original file line number Diff line number Diff line change
@@ -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
}
167 changes: 167 additions & 0 deletions backend/internal/handlers/invitation_handler.go
Original file line number Diff line number Diff line change
@@ -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()})
}
}
27 changes: 27 additions & 0 deletions backend/internal/models/group_invitation.go
Original file line number Diff line number Diff line change
@@ -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"`
}
57 changes: 57 additions & 0 deletions backend/internal/repositories/invitation_repository.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 22 additions & 0 deletions backend/internal/routes/invitation.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions backend/internal/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -27,4 +28,5 @@ func SetupRoutes(
setupLiveKitRoutes(api, livekitHandler, cfg)
setupLobbyRoutes(app, api, lobbyHandler, lobbyWSHandler)
setupGroupRoutes(api, groupHandler, cfg)
setupInvitationRoutes(api, invitationHandler, cfg)
}
Loading