add invite to space feature
This commit is contained in:
parent
109821d0a0
commit
4d6e6799a0
10 changed files with 407 additions and 8 deletions
|
|
@ -21,6 +21,7 @@ type App struct {
|
||||||
TagService *service.TagService
|
TagService *service.TagService
|
||||||
ShoppingListService *service.ShoppingListService
|
ShoppingListService *service.ShoppingListService
|
||||||
ExpenseService *service.ExpenseService
|
ExpenseService *service.ExpenseService
|
||||||
|
InviteService *service.InviteService
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) (*App, error) {
|
func New(cfg *config.Config) (*App, error) {
|
||||||
|
|
@ -45,6 +46,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
shoppingListRepository := repository.NewShoppingListRepository(database)
|
shoppingListRepository := repository.NewShoppingListRepository(database)
|
||||||
listItemRepository := repository.NewListItemRepository(database)
|
listItemRepository := repository.NewListItemRepository(database)
|
||||||
expenseRepository := repository.NewExpenseRepository(database)
|
expenseRepository := repository.NewExpenseRepository(database)
|
||||||
|
invitationRepository := repository.NewInvitationRepository(database)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
userService := service.NewUserService(userRepository)
|
userService := service.NewUserService(userRepository)
|
||||||
|
|
@ -71,6 +73,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
tagService := service.NewTagService(tagRepository)
|
tagService := service.NewTagService(tagRepository)
|
||||||
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
|
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
|
||||||
expenseService := service.NewExpenseService(expenseRepository)
|
expenseService := service.NewExpenseService(expenseRepository)
|
||||||
|
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
|
|
@ -83,6 +86,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
TagService: tagService,
|
TagService: tagService,
|
||||||
ShoppingListService: shoppingListService,
|
ShoppingListService: shoppingListService,
|
||||||
ExpenseService: expenseService,
|
ExpenseService: expenseService,
|
||||||
|
InviteService: inviteService,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -15,11 +15,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type authHandler struct {
|
type authHandler struct {
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
|
inviteService *service.InviteService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(authService *service.AuthService) *authHandler {
|
func NewAuthHandler(authService *service.AuthService, inviteService *service.InviteService) *authHandler {
|
||||||
return &authHandler{authService: authService}
|
return &authHandler{authService: authService, inviteService: inviteService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *authHandler) AuthPage(w http.ResponseWriter, r *http.Request) {
|
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))
|
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)
|
needsOnboarding, err := h.authService.NeedsOnboarding(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to check onboarding status", "error", err, "user_id", user.ID)
|
slog.Warn("failed to check onboarding status", "error", err, "user_id", user.ID)
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,16 @@ type SpaceHandler struct {
|
||||||
tagService *service.TagService
|
tagService *service.TagService
|
||||||
listService *service.ShoppingListService
|
listService *service.ShoppingListService
|
||||||
expenseService *service.ExpenseService
|
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{
|
return &SpaceHandler{
|
||||||
spaceService: ss,
|
spaceService: ss,
|
||||||
tagService: ts,
|
tagService: ts,
|
||||||
listService: sls,
|
listService: sls,
|
||||||
expenseService: es,
|
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))
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
22
internal/model/invitation.go
Normal file
22
internal/model/invitation.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
67
internal/repository/invitation.go
Normal file
67
internal/repository/invitation.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(a *app.App) http.Handler {
|
func SetupRoutes(a *app.App) http.Handler {
|
||||||
auth := handler.NewAuthHandler(a.AuthService)
|
auth := handler.NewAuthHandler(a.AuthService, a.InviteService)
|
||||||
home := handler.NewHomeHandler()
|
home := handler.NewHomeHandler()
|
||||||
dashboard := handler.NewDashboardHandler()
|
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()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
|
@ -103,6 +103,13 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler)
|
createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler)
|
||||||
mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess)
|
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
|
// 404
|
||||||
mux.HandleFunc("/{path...}", home.NotFoundPage)
|
mux.HandleFunc("/{path...}", home.NotFoundPage)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,29 @@ func (s *EmailService) SendWelcomeEmail(email, name string) error {
|
||||||
return err
|
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) {
|
func magicLinkEmailTemplate(magicURL, appName string) (string, string) {
|
||||||
subject := fmt.Sprintf("Sign in to %s", appName)
|
subject := fmt.Sprintf("Sign in to %s", appName)
|
||||||
body := fmt.Sprintf(`Click this link to sign in to your account:
|
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
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
112
internal/service/invite.go
Normal file
112
internal/service/invite.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,56 @@ package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
"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"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ SpaceDashboardPage(space *model.Space, lists []*model.ShoppingList, tags []*model.Tag) {
|
templ SpaceDashboardPage(space *model.Space, lists []*model.ShoppingList, tags []*model.Tag) {
|
||||||
@layouts.Space("Dashboard", space) {
|
@layouts.Space("Dashboard", space) {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h1 class="text-2xl font-bold">Welcome to { space.Name }!</h1>
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-2xl font-bold">Welcome to { space.Name }!</h1>
|
||||||
|
@dialog.Dialog(dialog.Props{ ID: "invite-member-dialog" }) {
|
||||||
|
@dialog.Trigger() {
|
||||||
|
@button.Button() {
|
||||||
|
Invite Member
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@dialog.Content() {
|
||||||
|
@dialog.Header() {
|
||||||
|
@dialog.Title() { Invite Member }
|
||||||
|
@dialog.Description() {
|
||||||
|
Send an invitation email to add a new member to this space.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<form
|
||||||
|
hx-post={ "/app/spaces/" + space.ID + "/invites" }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
@csrf.Token()
|
||||||
|
<div>
|
||||||
|
<label for="email" class="label">Email Address</label>
|
||||||
|
@input.Input(input.Props{
|
||||||
|
Name: "email",
|
||||||
|
ID: "email",
|
||||||
|
Type: "email",
|
||||||
|
Attributes: templ.Attributes{"required": "true"},
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
@button.Button(button.Props{ Type: button.TypeSubmit }) {
|
||||||
|
Send Invitation
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
// Shopping Lists section
|
// Shopping Lists section
|
||||||
<div class="border rounded-lg p-4">
|
<div class="border rounded-lg p-4">
|
||||||
|
|
@ -39,4 +82,4 @@ templ SpaceDashboardPage(space *model.Space, lists []*model.ShoppingList, tags [
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue