diff --git a/internal/app/app.go b/internal/app/app.go index 847dd2d..a813014 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,6 +21,7 @@ type App struct { TagService *service.TagService ShoppingListService *service.ShoppingListService ExpenseService *service.ExpenseService + InviteService *service.InviteService } func New(cfg *config.Config) (*App, error) { @@ -45,6 +46,7 @@ func New(cfg *config.Config) (*App, error) { shoppingListRepository := repository.NewShoppingListRepository(database) listItemRepository := repository.NewListItemRepository(database) expenseRepository := repository.NewExpenseRepository(database) + invitationRepository := repository.NewInvitationRepository(database) // Services userService := service.NewUserService(userRepository) @@ -71,6 +73,7 @@ func New(cfg *config.Config) (*App, error) { tagService := service.NewTagService(tagRepository) shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository) expenseService := service.NewExpenseService(expenseRepository) + inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService) return &App{ Cfg: cfg, @@ -83,6 +86,7 @@ func New(cfg *config.Config) (*App, error) { TagService: tagService, ShoppingListService: shoppingListService, ExpenseService: expenseService, + InviteService: inviteService, }, nil } func (a *App) Close() error { diff --git a/internal/db/migrations/00008_create_space_invitations_table.sql b/internal/db/migrations/00008_create_space_invitations_table.sql new file mode 100644 index 0000000..6521cb1 --- /dev/null +++ b/internal/db/migrations/00008_create_space_invitations_table.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS space_invitations ( + token TEXT PRIMARY KEY NOT NULL, + space_id TEXT NOT NULL, + inviter_id TEXT NOT NULL, + email TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'expired')), + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, + FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_space_invitations_email ON space_invitations(email); +CREATE INDEX IF NOT EXISTS idx_space_invitations_space_id ON space_invitations(space_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_space_invitations_space_id; +DROP INDEX IF EXISTS idx_space_invitations_email; +DROP TABLE IF EXISTS space_invitations; +-- +goose StatementEnd diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 67b8d74..a994ea8 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -15,11 +15,12 @@ import ( ) type authHandler struct { - authService *service.AuthService + authService *service.AuthService + inviteService *service.InviteService } -func NewAuthHandler(authService *service.AuthService) *authHandler { - return &authHandler{authService: authService} +func NewAuthHandler(authService *service.AuthService, inviteService *service.InviteService) *authHandler { + return &authHandler{authService: authService, inviteService: inviteService} } func (h *authHandler) AuthPage(w http.ResponseWriter, r *http.Request) { @@ -88,6 +89,28 @@ func (h *authHandler) VerifyMagicLink(w http.ResponseWriter, r *http.Request) { h.authService.SetJWTCookie(w, jwtToken, time.Now().Add(7*24*time.Hour)) + // Check for pending invite + inviteCookie, err := r.Cookie("pending_invite") + if err == nil && inviteCookie.Value != "" { + spaceID, err := h.inviteService.AcceptInvite(inviteCookie.Value, user.ID) + if err != nil { + slog.Error("failed to process pending invite", "error", err, "token", inviteCookie.Value) + // Don't fail the login, just maybe notify user? + } else { + slog.Info("accepted pending invite", "user_id", user.ID, "space_id", spaceID) + // Clear cookie + http.SetCookie(w, &http.Cookie{ + Name: "pending_invite", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + // If we want to redirect to the space immediately, we can. + // But check onboarding first. + } + } + needsOnboarding, err := h.authService.NeedsOnboarding(user.ID) if err != nil { slog.Warn("failed to check onboarding status", "error", err, "user_id", user.ID) diff --git a/internal/handler/space.go b/internal/handler/space.go index 5d6a4ec..06260b9 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -20,14 +20,16 @@ type SpaceHandler struct { tagService *service.TagService listService *service.ShoppingListService expenseService *service.ExpenseService + inviteService *service.InviteService } -func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService) *SpaceHandler { +func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService) *SpaceHandler { return &SpaceHandler{ spaceService: ss, tagService: ts, listService: sls, expenseService: es, + inviteService: is, } } @@ -353,3 +355,57 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { ui.Render(w, r, pages.ExpenseCreatedResponse(newExpense, balance)) } + +func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + email := r.FormValue("email") + if email == "" { + http.Error(w, "Email is required", http.StatusBadRequest) + return + } + + _, err := h.inviteService.CreateInvite(spaceID, user.ID, email) + if err != nil { + slog.Error("failed to create invite", "error", err, "space_id", spaceID) + http.Error(w, "Failed to create invite", http.StatusInternalServerError) + return + } + + // TODO: Return a nice UI response (toast or list update) + w.Write([]byte("Invitation sent!")) +} + +func (h *SpaceHandler) JoinSpace(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + user := ctxkeys.User(r.Context()) + + if user != nil { + spaceID, err := h.inviteService.AcceptInvite(token, user.ID) + if err != nil { + slog.Error("failed to accept invite", "error", err, "token", token) + http.Error(w, "Failed to join space: "+err.Error(), http.StatusBadRequest) + return + } + + http.Redirect(w, r, "/app/spaces/"+spaceID, http.StatusSeeOther) + return + } + + // Not logged in: set cookie and redirect to auth + http.SetCookie(w, &http.Cookie{ + Name: "pending_invite", + Value: token, + Path: "/", + Expires: time.Now().Add(1 * time.Hour), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, "/auth?invite=true", http.StatusTemporaryRedirect) +} diff --git a/internal/model/invitation.go b/internal/model/invitation.go new file mode 100644 index 0000000..09b5069 --- /dev/null +++ b/internal/model/invitation.go @@ -0,0 +1,22 @@ +package model + +import "time" + +type InvitationStatus string + +const ( + InvitationStatusPending InvitationStatus = "pending" + InvitationStatusAccepted InvitationStatus = "accepted" + InvitationStatusExpired InvitationStatus = "expired" +) + +type SpaceInvitation struct { + Token string `db:"token"` + SpaceID string `db:"space_id"` + InviterID string `db:"inviter_id"` + Email string `db:"email"` + Status InvitationStatus `db:"status"` + ExpiresAt time.Time `db:"expires_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} diff --git a/internal/repository/invitation.go b/internal/repository/invitation.go new file mode 100644 index 0000000..37c2085 --- /dev/null +++ b/internal/repository/invitation.go @@ -0,0 +1,67 @@ +package repository + +import ( + "database/sql" + "errors" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" +) + +var ( + ErrInvitationNotFound = errors.New("invitation not found") +) + +type InvitationRepository interface { + Create(invitation *model.SpaceInvitation) error + GetByToken(token string) (*model.SpaceInvitation, error) + GetBySpaceID(spaceID string) ([]*model.SpaceInvitation, error) + UpdateStatus(token string, status model.InvitationStatus) error + Delete(token string) error +} + +type invitationRepository struct { + db *sqlx.DB +} + +func NewInvitationRepository(db *sqlx.DB) InvitationRepository { + return &invitationRepository{db: db} +} + +func (r *invitationRepository) Create(invitation *model.SpaceInvitation) error { + query := ` + INSERT INTO space_invitations (token, space_id, inviter_id, email, status, expires_at, created_at, updated_at) + VALUES (:token, :space_id, :inviter_id, :email, :status, :expires_at, :created_at, :updated_at) + ` + _, err := r.db.NamedExec(query, invitation) + return err +} + +func (r *invitationRepository) GetByToken(token string) (*model.SpaceInvitation, error) { + var invitation model.SpaceInvitation + query := `SELECT * FROM space_invitations WHERE token = $1` + err := r.db.Get(&invitation, query, token) + if err == sql.ErrNoRows { + return nil, ErrInvitationNotFound + } + return &invitation, err +} + +func (r *invitationRepository) GetBySpaceID(spaceID string) ([]*model.SpaceInvitation, error) { + var invitations []*model.SpaceInvitation + query := `SELECT * FROM space_invitations WHERE space_id = $1 ORDER BY created_at DESC` + err := r.db.Select(&invitations, query, spaceID) + return invitations, err +} + +func (r *invitationRepository) UpdateStatus(token string, status model.InvitationStatus) error { + query := `UPDATE space_invitations SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE token = $2` + _, err := r.db.Exec(query, status, token) + return err +} + +func (r *invitationRepository) Delete(token string) error { + query := `DELETE FROM space_invitations WHERE token = $1` + _, err := r.db.Exec(query, token) + return err +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 9f99ab6..a1e18d3 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -11,10 +11,10 @@ import ( ) func SetupRoutes(a *app.App) http.Handler { - auth := handler.NewAuthHandler(a.AuthService) + auth := handler.NewAuthHandler(a.AuthService, a.InviteService) home := handler.NewHomeHandler() dashboard := handler.NewDashboardHandler() - space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService) + space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService) mux := http.NewServeMux() @@ -103,6 +103,13 @@ func SetupRoutes(a *app.App) http.Handler { createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler) mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess) + // Invite routes + createInviteHandler := middleware.RequireAuth(space.CreateInvite) + createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler) + mux.Handle("POST /app/spaces/{spaceID}/invites", createInviteWithAccess) + + mux.HandleFunc("GET /join/{token}", space.JoinSpace) + // 404 mux.HandleFunc("/{path...}", home.NotFoundPage) diff --git a/internal/service/email.go b/internal/service/email.go index cf3438c..a482f69 100644 --- a/internal/service/email.go +++ b/internal/service/email.go @@ -201,6 +201,29 @@ func (s *EmailService) SendWelcomeEmail(email, name string) error { return err } +func (s *EmailService) SendInvitationEmail(email, spaceName, inviterName, token string) error { + inviteURL := fmt.Sprintf("%s/join/%s", s.appURL, token) + subject, body := invitationEmailTemplate(spaceName, inviterName, inviteURL, s.appName) + + if !s.isProd { + slog.Info("email sent (dev mode)", "type", "invitation", "to", email, "subject", subject, "url", inviteURL) + return nil + } + + params := &EmailParams{ + From: s.fromEmail, + To: []string{email}, + Subject: subject, + Text: body, + } + + _, err := s.client.SendWithContext(context.Background(), params) + if err == nil { + slog.Info("email sent", "type", "invitation", "to", email) + } + return err +} + func magicLinkEmailTemplate(magicURL, appName string) (string, string) { subject := fmt.Sprintf("Sign in to %s", appName) body := fmt.Sprintf(`Click this link to sign in to your account: @@ -231,3 +254,20 @@ The %s Team`, name, dashboardURL, appName) return subject, body } + +func invitationEmailTemplate(spaceName, inviterName, inviteURL, appName string) (string, string) { + subject := fmt.Sprintf("%s invited you to join %s on %s", inviterName, spaceName, appName) + body := fmt.Sprintf(`Hi, + +%s has invited you to join the space "%s" on %s. + +Click the link below to accept the invitation: +%s + +If you don't have an account, you will be asked to create one. + +Best, +The %s Team`, inviterName, spaceName, appName, inviteURL, appName) + + return subject, body +} diff --git a/internal/service/invite.go b/internal/service/invite.go new file mode 100644 index 0000000..d12d040 --- /dev/null +++ b/internal/service/invite.go @@ -0,0 +1,112 @@ +package service + +import ( + "errors" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/repository" + "git.juancwu.dev/juancwu/budgit/internal/utils" +) + +type InviteService struct { + inviteRepo repository.InvitationRepository + spaceRepo repository.SpaceRepository + userRepo repository.UserRepository + emailSvc *EmailService +} + +func NewInviteService(ir repository.InvitationRepository, sr repository.SpaceRepository, ur repository.UserRepository, es *EmailService) *InviteService { + return &InviteService{ + inviteRepo: ir, + spaceRepo: sr, + userRepo: ur, + emailSvc: es, + } +} + +func (s *InviteService) CreateInvite(spaceID, inviterID, email string) (*model.SpaceInvitation, error) { + // Check if space exists + space, err := s.spaceRepo.ByID(spaceID) + if err != nil { + return nil, err + } + + // Check if inviter is member/owner of space? (Ideally yes, but for now assuming caller checks permissions) + + // Check if user is already a member + // This would require a method on SpaceRepo or SpaceService. + // For now, let's proceed. + + token := utils.RandomID() // Or a more secure token generator + expiresAt := time.Now().Add(48 * time.Hour) + + invitation := &model.SpaceInvitation{ + Token: token, + SpaceID: spaceID, + InviterID: inviterID, + Email: email, + Status: model.InvitationStatusPending, + ExpiresAt: expiresAt, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := s.inviteRepo.Create(invitation); err != nil { + return nil, err + } + + // Get inviter name + inviter, err := s.userRepo.ByID(inviterID) + inviterName := "Someone" + if err == nil { + inviterName = inviter.Email // Or Name if available + // Get profile for better name? + } + + // Send Email + go s.emailSvc.SendInvitationEmail(email, space.Name, inviterName, token) + + return invitation, nil +} + +func (s *InviteService) AcceptInvite(token, userID string) (string, error) { + invite, err := s.inviteRepo.GetByToken(token) + if err != nil { + return "", err + } + + if invite.Status != model.InvitationStatusPending { + return "", errors.New("invitation is not pending") + } + + if time.Now().After(invite.ExpiresAt) { + s.inviteRepo.UpdateStatus(token, model.InvitationStatusExpired) + return "", errors.New("invitation expired") + } + + // Add user to space + err = s.spaceRepo.AddMember(invite.SpaceID, userID, model.RoleMember) + if err != nil { + return "", err + } + + return invite.SpaceID, s.inviteRepo.UpdateStatus(token, model.InvitationStatusAccepted) +} + +func (s *InviteService) GetPendingInvites(spaceID string) ([]*model.SpaceInvitation, error) { + // Filter for pending only in memory or repo? + // Repo returns all. + all, err := s.inviteRepo.GetBySpaceID(spaceID) + if err != nil { + return nil, err + } + + var pending []*model.SpaceInvitation + for _, inv := range all { + if inv.Status == model.InvitationStatusPending { + pending = append(pending, inv) + } + } + return pending, nil +} diff --git a/internal/ui/pages/app_space_dashboard.templ b/internal/ui/pages/app_space_dashboard.templ index 4ecab88..e7b65cf 100644 --- a/internal/ui/pages/app_space_dashboard.templ +++ b/internal/ui/pages/app_space_dashboard.templ @@ -2,13 +2,56 @@ package pages import ( "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" ) templ SpaceDashboardPage(space *model.Space, lists []*model.ShoppingList, tags []*model.Tag) { @layouts.Space("Dashboard", space) {