feat: invite management API: DELETE /v1/user/invite/:id (204, cancels a pending invite) and POST /v1/user/invite/:id/renew (200, new code, expires_at = now+3d, created_at = now, resends the invitation email, old link dead)

This commit is contained in:
fsylvain committed 2026-09-30 01:36:43 +02:00
1 parent 461e93e3b5
commit 153fd8719a
18 files changed
+1147 -44

No files matched your search

@@ -45,6 +45,25 @@ func (i *InviteRepository) Delete(id uint32) error {
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
}
func (i *InviteRepository) Renew(id uint32, hashedCode string, expiresAt time.Time, createdAt time.Time) (domainInvite.Invite, error) {
result := i.db.Model(&entity.Invite{}).
Where("id = ? AND accepted_at IS NULL", id).
Updates(map[string]any{
"hashed_code": hashedCode,
"expires_at": expiresAt,
"created_at": createdAt,
})
if err := deleteOrNotFound(result, domainInvite.ErrInviteNotFound); err != nil {
return domainInvite.Invite{}, err
}
var model entity.Invite
if err := i.db.First(&model, id).Error; err != nil {
return domainInvite.Invite{}, err
}
return mappers.InviteToDomain(model), nil
}
func (i *InviteRepository) AcceptWithUser(inviteID uint32, acceptedAt time.Time, newUser domainUser.User) (domainUser.User, error) {
createdModel := mappers.UserToModel(newUser)
err := i.db.Transaction(func(tx *gorm.DB) error {
@@ -0,0 +1,18 @@
package useCases
import "RenewCMS/internal/domain/invite"
type CancelInviteUseCase struct {
inviteRepository invite.Repository
}
func NewCancelInviteUseCase(inviteRepository invite.Repository) *CancelInviteUseCase {
return &CancelInviteUseCase{inviteRepository}
}
func (c *CancelInviteUseCase) CancelInvite(id uint32) error {
if _, err := findManageableInvite(c.inviteRepository, id); err != nil {
return err
}
return c.inviteRepository.Delete(id)
}
@@ -6,21 +6,15 @@ import (
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const inviteTemplateName = "invitation"
type CreateInviteUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
hasher hash.Hasher
mailRepository mail.Repository
publicURL string
mailer inviteMailer
}
type CreateInviteCommand struct {
@@ -28,20 +22,8 @@ type CreateInviteCommand struct {
InvitedBy uint32
}
type MailInviteData struct {
Host string
VerificationCode string
Email string
InvitedBy string
}
func (d MailInviteData) Subject() string {
inviter := strings.NewReplacer("\r", " ", "\n", " ").Replace(d.InvitedBy)
return inviter + " invited you to RenewCMS"
}
func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *CreateInviteUseCase {
return &CreateInviteUseCase{userRepository, inviteRepository, hasher, mailRepository, publicURL}
return &CreateInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}}
}
func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.Invite, error) {
@@ -61,8 +43,7 @@ func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.
return invite.Invite{}, err
}
rawCode := uuid.NewString()
hashedCode, err := g.hasher.Hash(rawCode)
rawCode, hashedCode, err := newInviteCode(g.hasher)
if err != nil {
return invite.Invite{}, err
}
@@ -78,13 +59,7 @@ func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.
return invite.Invite{}, err
}
if err := g.mailRepository.Send(email, inviteTemplateName, MailInviteData{
Host: g.publicURL,
VerificationCode: rawCode,
Email: email,
InvitedBy: inviterName(inviter),
}); err != nil {
sendErr := fmt.Errorf("%w: %w", invite.ErrInviteEmailNotSent, err)
if sendErr := g.mailer.send(email, rawCode, inviter); sendErr != nil {
if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil {
return invite.Invite{}, errors.Join(sendErr, deleteErr)
}
@@ -119,10 +94,3 @@ func (g *CreateInviteUseCase) discardOpenInvite(email string, now time.Time) err
}
return nil
}
func inviterName(inviter user.User) string {
if inviter.DisplayName != "" {
return inviter.DisplayName
}
return inviter.Username
}
@@ -0,0 +1,59 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"errors"
"time"
)
type RenewInviteUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
hasher hash.Hasher
mailer inviteMailer
}
type RenewInviteCommand struct {
ID uint32
RenewedBy uint32
}
func NewRenewInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *RenewInviteUseCase {
return &RenewInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}}
}
func (u *RenewInviteUseCase) RenewInvite(command RenewInviteCommand) (invite.Invite, error) {
now := time.Now()
renewer, err := u.userRepository.Get(command.RenewedBy)
if err != nil {
return invite.Invite{}, err
}
previous, err := findManageableInvite(u.inviteRepository, command.ID)
if err != nil {
return invite.Invite{}, err
}
rawCode, hashedCode, err := newInviteCode(u.hasher)
if err != nil {
return invite.Invite{}, err
}
renewed, err := u.inviteRepository.Renew(previous.ID, hashedCode, now.Add(invite.Lifetime), now)
if err != nil {
return invite.Invite{}, err
}
if sendErr := u.mailer.send(renewed.Email, rawCode, renewer); sendErr != nil {
if _, restoreErr := u.inviteRepository.Renew(previous.ID, previous.HashedCode, previous.ExpiresAt, previous.CreatedAt); restoreErr != nil {
return invite.Invite{}, errors.Join(sendErr, restoreErr)
}
return invite.Invite{}, sendErr
}
return renewed, nil
}
@@ -0,0 +1,16 @@
package useCases
import "RenewCMS/internal/domain/invite"
func findManageableInvite(inviteRepository invite.Repository, id uint32) (invite.Invite, error) {
for _, existing := range inviteRepository.GetAll() {
if existing.ID != id {
continue
}
if existing.AcceptedAt != nil {
return invite.Invite{}, invite.ErrInviteAlreadyAccepted
}
return existing, nil
}
return invite.Invite{}, invite.ErrInviteNotFound
}
@@ -0,0 +1,59 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"fmt"
"strings"
"github.com/google/uuid"
)
const inviteTemplateName = "invitation"
type MailInviteData struct {
Host string
VerificationCode string
Email string
InvitedBy string
}
func (d MailInviteData) Subject() string {
inviter := strings.NewReplacer("\r", " ", "\n", " ").Replace(d.InvitedBy)
return inviter + " invited you to RenewCMS"
}
type inviteMailer struct {
mailRepository mail.Repository
publicURL string
}
func (m inviteMailer) send(email string, rawCode string, inviter user.User) error {
if err := m.mailRepository.Send(email, inviteTemplateName, MailInviteData{
Host: m.publicURL,
VerificationCode: rawCode,
Email: email,
InvitedBy: inviterName(inviter),
}); err != nil {
return fmt.Errorf("%w: %w", invite.ErrInviteEmailNotSent, err)
}
return nil
}
func newInviteCode(hasher hash.Hasher) (string, string, error) {
rawCode := uuid.NewString()
hashedCode, err := hasher.Hash(rawCode)
if err != nil {
return "", "", err
}
return rawCode, hashedCode, nil
}
func inviterName(inviter user.User) string {
if inviter.DisplayName != "" {
return inviter.DisplayName
}
return inviter.Username
}