feat(guild): separate scout invitations into guild_invites table

Scout invitations were stored in guild_applications with type 'invited',
forcing the scout list response to use charID as the invitation ID — a
known hack that made CancelGuildScout semantically incorrect.

Introduce a dedicated guild_invites table (migration 0012) with a serial
PK. The scout list now returns real invite IDs and actual InvitedAt
timestamps. CancelGuildScout cancels by PK. AcceptInvite and DeclineInvite
operate on guild_invites while player-applied applications remain in
guild_applications unchanged.
This commit is contained in:
Houmgaor
2026-03-21 17:59:25 +01:00
parent a67b10abbc
commit dbbfb927f8
10 changed files with 230 additions and 135 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Guild scout invitations now use a dedicated `guild_invites` table (migration `0012_guild_invites`), giving each invitation a real serial PK; the scout list response now returns accurate invite IDs and timestamps, and `CancelGuildScout` uses the correct PK instead of the character ID.
- Event Tent (campaign) system: code redemption, stamp tracking, reward claiming, and quest gating for special event quests, backed by 8 new database tables and seeded with community-researched live-game campaign data ([#182](https://github.com/Mezeporta/Erupe/pull/182), by stratick).
- Database migration `0010_campaign` (campaigns, campaign_categories, campaign_category_links, campaign_rewards, campaign_rewards_claimed, campaign_state, campaign_codes, campaign_quest).
- JSON Hunting Road config: `bin/rengoku_data.json` is now supported as a human-readable alternative to the opaque `rengoku_data.bin` — the server assembles and ECD-encrypts the binary at startup, with `.bin` used as a fallback ([#173](https://github.com/Mezeporta/Erupe/issues/173)).

View File

@@ -53,7 +53,7 @@ func handleMsgMhfCancelGuildScout(s *Session, p mhfpacket.MHFPacket) {
return
}
err = s.server.guildRepo.CancelInvitation(guild.ID, pkt.InvitationID)
err = s.server.guildRepo.CancelInvite(pkt.InvitationID)
if err != nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
@@ -123,28 +123,25 @@ func handleMsgMhfGetGuildScoutList(s *Session, p mhfpacket.MHFPacket) {
}
}
chars, err := s.server.guildRepo.ListInvitedCharacters(guildInfo.ID)
invites, err := s.server.guildRepo.ListInvites(guildInfo.ID)
if err != nil {
s.logger.Error("failed to retrieve scouted characters", zap.Error(err))
s.logger.Error("failed to retrieve scout invites", zap.Error(err))
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
return
}
bf := byteframe.NewByteFrame()
bf.SetBE()
bf.WriteUint32(uint32(len(chars)))
bf.WriteUint32(uint32(len(invites)))
for _, sc := range chars {
// This seems to be used as a unique ID for the invitation sent
// we can just use the charID and then filter on guild_id+charID when performing operations
// this might be a problem later with mails sent referencing IDs but we'll see.
bf.WriteUint32(sc.CharID)
bf.WriteUint32(sc.ActorID)
bf.WriteUint32(sc.CharID)
bf.WriteUint32(uint32(TimeAdjusted().Unix()))
bf.WriteUint16(sc.HR)
bf.WriteUint16(sc.GR)
bf.WriteBytes(stringsupport.PaddedString(sc.Name, 32, true))
for _, inv := range invites {
bf.WriteUint32(inv.ID)
bf.WriteUint32(inv.ActorID)
bf.WriteUint32(inv.CharID)
bf.WriteUint32(uint32(inv.InvitedAt.Unix()))
bf.WriteUint16(inv.HR)
bf.WriteUint16(inv.GR)
bf.WriteBytes(stringsupport.PaddedString(inv.Name, 32, true))
}
doAckBufSucceed(s, pkt.AckHandle, bf.Data())

View File

@@ -12,7 +12,7 @@ func TestAnswerGuildScout_Accept(t *testing.T) {
server := createMockServer()
mailMock := &mockMailRepo{}
guildMock := &mockGuildRepo{
application: &GuildApplication{GuildID: 10, CharID: 1},
hasInviteResult: true,
}
guildMock.guild = &Guild{ID: 10, Name: "TestGuild"}
guildMock.guild.LeaderCharID = 50
@@ -29,8 +29,8 @@ func TestAnswerGuildScout_Accept(t *testing.T) {
handleMsgMhfAnswerGuildScout(session, pkt)
if guildMock.acceptedCharID != 1 {
t.Errorf("AcceptApplication charID = %d, want 1", guildMock.acceptedCharID)
if guildMock.acceptInviteCharID != 1 {
t.Errorf("AcceptInvite charID = %d, want 1", guildMock.acceptInviteCharID)
}
if len(mailMock.sentMails) != 2 {
t.Fatalf("Expected 2 mails (self + leader), got %d", len(mailMock.sentMails))
@@ -47,7 +47,7 @@ func TestAnswerGuildScout_Decline(t *testing.T) {
server := createMockServer()
mailMock := &mockMailRepo{}
guildMock := &mockGuildRepo{
application: &GuildApplication{GuildID: 10, CharID: 1},
hasInviteResult: true,
}
guildMock.guild = &Guild{ID: 10, Name: "TestGuild"}
guildMock.guild.LeaderCharID = 50
@@ -64,8 +64,8 @@ func TestAnswerGuildScout_Decline(t *testing.T) {
handleMsgMhfAnswerGuildScout(session, pkt)
if guildMock.rejectedCharID != 1 {
t.Errorf("RejectApplication charID = %d, want 1", guildMock.rejectedCharID)
if guildMock.declineInviteCharID != 1 {
t.Errorf("DeclineInvite charID = %d, want 1", guildMock.declineInviteCharID)
}
if len(mailMock.sentMails) != 2 {
t.Fatalf("Expected 2 mails (self + leader), got %d", len(mailMock.sentMails))
@@ -101,7 +101,7 @@ func TestAnswerGuildScout_ApplicationMissing(t *testing.T) {
server := createMockServer()
mailMock := &mockMailRepo{}
guildMock := &mockGuildRepo{
application: nil, // no application found
hasInviteResult: false, // no invite found
}
guildMock.guild = &Guild{ID: 10, Name: "TestGuild"}
guildMock.guild.LeaderCharID = 50
@@ -134,7 +134,7 @@ func TestAnswerGuildScout_MailError(t *testing.T) {
server := createMockServer()
mailMock := &mockMailRepo{sendErr: errNotFound}
guildMock := &mockGuildRepo{
application: &GuildApplication{GuildID: 10, CharID: 1},
hasInviteResult: true,
}
guildMock.guild = &Guild{ID: 10, Name: "TestGuild"}
guildMock.guild.LeaderCharID = 50

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
@@ -270,8 +271,9 @@ func (r *GuildRepository) CreateApplication(guildID, charID, actorID uint32, app
return err
}
// CreateApplicationWithMail atomically creates an application and sends a notification mail.
func (r *GuildRepository) CreateApplicationWithMail(guildID, charID, actorID uint32, appType GuildApplicationType, mailSenderID, mailRecipientID uint32, mailSubject, mailBody string) error {
// CreateInviteWithMail atomically inserts a scout invitation into guild_invites
// and sends a notification mail to the target character.
func (r *GuildRepository) CreateInviteWithMail(guildID, charID, actorID uint32, mailSenderID, mailRecipientID uint32, mailSubject, mailBody string) error {
tx, err := r.db.BeginTxx(context.Background(), nil)
if err != nil {
return err
@@ -279,8 +281,8 @@ func (r *GuildRepository) CreateApplicationWithMail(guildID, charID, actorID uin
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(
`INSERT INTO guild_applications (guild_id, character_id, actor_id, application_type) VALUES ($1, $2, $3, $4)`,
guildID, charID, actorID, appType); err != nil {
`INSERT INTO guild_invites (guild_id, character_id, actor_id) VALUES ($1, $2, $3)`,
guildID, charID, actorID); err != nil {
return err
}
if _, err := tx.Exec(mailInsertQuery, mailSenderID, mailRecipientID, mailSubject, mailBody, 0, 0, true, false); err != nil {
@@ -289,11 +291,55 @@ func (r *GuildRepository) CreateApplicationWithMail(guildID, charID, actorID uin
return tx.Commit()
}
// CancelInvitation removes an invitation for a character.
func (r *GuildRepository) CancelInvitation(guildID, charID uint32) error {
// HasInvite reports whether a pending scout invitation exists for the character in the guild.
func (r *GuildRepository) HasInvite(guildID, charID uint32) (bool, error) {
var n int
err := r.db.QueryRow(
`SELECT 1 FROM guild_invites WHERE guild_id = $1 AND character_id = $2`,
guildID, charID,
).Scan(&n)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// CancelInvite removes a scout invitation by its primary key.
func (r *GuildRepository) CancelInvite(inviteID uint32) error {
_, err := r.db.Exec(`DELETE FROM guild_invites WHERE id = $1`, inviteID)
return err
}
// AcceptInvite removes the scout invitation and adds the character to the guild atomically.
func (r *GuildRepository) AcceptInvite(guildID, charID uint32) error {
tx, err := r.db.BeginTxx(context.Background(), nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(
`DELETE FROM guild_invites WHERE guild_id = $1 AND character_id = $2`,
guildID, charID); err != nil {
return err
}
if _, err := tx.Exec(`
INSERT INTO guild_characters (guild_id, character_id, order_index)
VALUES ($1, $2, (SELECT MAX(order_index) + 1 FROM guild_characters WHERE guild_id = $1))
`, guildID, charID); err != nil {
return err
}
return tx.Commit()
}
// DeclineInvite removes a scout invitation without joining the guild.
func (r *GuildRepository) DeclineInvite(guildID, charID uint32) error {
_, err := r.db.Exec(
`DELETE FROM guild_applications WHERE character_id = $1 AND guild_id = $2 AND application_type = 'invited'`,
charID, guildID,
`DELETE FROM guild_invites WHERE guild_id = $1 AND character_id = $2`,
guildID, charID,
)
return err
}
@@ -433,34 +479,39 @@ func (r *GuildRepository) SetRecruiter(charID uint32, allowed bool) error {
return err
}
// ScoutedCharacter represents an invited character in the scout list.
type ScoutedCharacter struct {
CharID uint32 `db:"id"`
Name string `db:"name"`
HR uint16 `db:"hr"`
GR uint16 `db:"gr"`
ActorID uint32 `db:"actor_id"`
// GuildInvite represents a pending scout invitation with the target character's info.
type GuildInvite struct {
ID uint32 `db:"id"`
GuildID uint32 `db:"guild_id"`
CharID uint32 `db:"character_id"`
ActorID uint32 `db:"actor_id"`
InvitedAt time.Time `db:"created_at"`
HR uint16 `db:"hr"`
GR uint16 `db:"gr"`
Name string `db:"name"`
}
// ListInvitedCharacters returns all characters with pending guild invitations.
func (r *GuildRepository) ListInvitedCharacters(guildID uint32) ([]*ScoutedCharacter, error) {
// ListInvites returns all pending scout invitations for a guild, including
// the target character's HR, GR, and name.
func (r *GuildRepository) ListInvites(guildID uint32) ([]*GuildInvite, error) {
rows, err := r.db.Queryx(`
SELECT c.id, c.name, c.hr, c.gr, ga.actor_id
FROM guild_applications ga
JOIN characters c ON c.id = ga.character_id
WHERE ga.guild_id = $1 AND ga.application_type = 'invited'
SELECT gi.id, gi.guild_id, gi.character_id, gi.actor_id, gi.created_at,
c.hr, c.gr, c.name
FROM guild_invites gi
JOIN characters c ON c.id = gi.character_id
WHERE gi.guild_id = $1
`, guildID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var chars []*ScoutedCharacter
var invites []*GuildInvite
for rows.Next() {
sc := &ScoutedCharacter{}
if err := rows.StructScan(sc); err != nil {
inv := &GuildInvite{}
if err := rows.StructScan(inv); err != nil {
continue
}
chars = append(chars, sc)
invites = append(invites, inv)
}
return chars, nil
return invites, nil
}

View File

@@ -533,66 +533,77 @@ func TestAddMemberDailyRP(t *testing.T) {
// --- Invitation / Scout tests ---
func TestCancelInvitation(t *testing.T) {
func TestCancelInvite(t *testing.T) {
repo, db, guildID, leaderID := setupGuildRepo(t)
user2 := CreateTestUser(t, db, "invite_user")
char2 := CreateTestCharacter(t, db, user2, "Invited")
if err := repo.CreateApplication(guildID, char2, leaderID, GuildApplicationTypeInvited); err != nil {
t.Fatalf("CreateApplication (invited) failed: %v", err)
if err := repo.CreateInviteWithMail(guildID, char2, leaderID, leaderID, char2, "Invite", "body"); err != nil {
t.Fatalf("CreateInviteWithMail failed: %v", err)
}
if err := repo.CancelInvitation(guildID, char2); err != nil {
t.Fatalf("CancelInvitation failed: %v", err)
invites, err := repo.ListInvites(guildID)
if err != nil || len(invites) != 1 {
t.Fatalf("Expected 1 invite, got %d (err: %v)", len(invites), err)
}
has, err := repo.HasApplication(guildID, char2)
if err := repo.CancelInvite(invites[0].ID); err != nil {
t.Fatalf("CancelInvite failed: %v", err)
}
has, err := repo.HasInvite(guildID, char2)
if err != nil {
t.Fatalf("HasApplication failed: %v", err)
t.Fatalf("HasInvite failed: %v", err)
}
if has {
t.Error("Expected no application after cancellation")
t.Error("Expected no invite after cancellation")
}
}
func TestListInvitedCharacters(t *testing.T) {
func TestListInvites(t *testing.T) {
repo, db, guildID, leaderID := setupGuildRepo(t)
user2 := CreateTestUser(t, db, "scout_user")
char2 := CreateTestCharacter(t, db, user2, "Scouted")
if err := repo.CreateApplication(guildID, char2, leaderID, GuildApplicationTypeInvited); err != nil {
t.Fatalf("CreateApplication failed: %v", err)
if err := repo.CreateInviteWithMail(guildID, char2, leaderID, leaderID, char2, "Invite", "body"); err != nil {
t.Fatalf("CreateInviteWithMail failed: %v", err)
}
chars, err := repo.ListInvitedCharacters(guildID)
invites, err := repo.ListInvites(guildID)
if err != nil {
t.Fatalf("ListInvitedCharacters failed: %v", err)
t.Fatalf("ListInvites failed: %v", err)
}
if len(chars) != 1 {
t.Fatalf("Expected 1 invited character, got %d", len(chars))
if len(invites) != 1 {
t.Fatalf("Expected 1 invite, got %d", len(invites))
}
if chars[0].CharID != char2 {
t.Errorf("Expected char ID %d, got %d", char2, chars[0].CharID)
if invites[0].CharID != char2 {
t.Errorf("Expected char ID %d, got %d", char2, invites[0].CharID)
}
if chars[0].Name != "Scouted" {
t.Errorf("Expected name 'Scouted', got %q", chars[0].Name)
if invites[0].Name != "Scouted" {
t.Errorf("Expected name 'Scouted', got %q", invites[0].Name)
}
if chars[0].ActorID != leaderID {
t.Errorf("Expected actor ID %d, got %d", leaderID, chars[0].ActorID)
if invites[0].ActorID != leaderID {
t.Errorf("Expected actor ID %d, got %d", leaderID, invites[0].ActorID)
}
if invites[0].ID == 0 {
t.Error("Expected non-zero invite ID")
}
if invites[0].InvitedAt.IsZero() {
t.Error("Expected non-zero InvitedAt timestamp")
}
}
func TestListInvitedCharactersEmpty(t *testing.T) {
func TestListInvitesEmpty(t *testing.T) {
repo, _, guildID, _ := setupGuildRepo(t)
chars, err := repo.ListInvitedCharacters(guildID)
invites, err := repo.ListInvites(guildID)
if err != nil {
t.Fatalf("ListInvitedCharacters failed: %v", err)
t.Fatalf("ListInvites failed: %v", err)
}
if len(chars) != 0 {
t.Errorf("Expected 0 invited characters, got %d", len(chars))
if len(invites) != 0 {
t.Errorf("Expected 0 invites, got %d", len(invites))
}
}
@@ -1486,28 +1497,26 @@ func TestDisbandCleansUpAlliance(t *testing.T) {
}
}
// --- CreateApplicationWithMail ---
// --- CreateInviteWithMail ---
func TestCreateApplicationWithMail(t *testing.T) {
func TestCreateInviteWithMail(t *testing.T) {
repo, db, guildID, leaderID := setupGuildRepo(t)
user2 := CreateTestUser(t, db, "scout_mail_user")
char2 := CreateTestCharacter(t, db, user2, "ScoutTarget")
err := repo.CreateApplicationWithMail(
guildID, char2, leaderID, GuildApplicationTypeInvited,
leaderID, char2, "Guild Invite", "You have been invited!")
err := repo.CreateInviteWithMail(guildID, char2, leaderID, leaderID, char2, "Guild Invite", "You have been invited!")
if err != nil {
t.Fatalf("CreateApplicationWithMail failed: %v", err)
t.Fatalf("CreateInviteWithMail failed: %v", err)
}
// Verify application was created
has, err := repo.HasApplication(guildID, char2)
// Verify invite was created
has, err := repo.HasInvite(guildID, char2)
if err != nil {
t.Fatalf("HasApplication failed: %v", err)
t.Fatalf("HasInvite failed: %v", err)
}
if !has {
t.Error("Expected application to exist after CreateApplicationWithMail")
t.Error("Expected invite to exist after CreateInviteWithMail")
}
// Verify mail was sent

View File

@@ -64,8 +64,11 @@ type GuildRepo interface {
RemoveCharacter(charID uint32) error
AcceptApplication(guildID, charID uint32) error
CreateApplication(guildID, charID, actorID uint32, appType GuildApplicationType) error
CreateApplicationWithMail(guildID, charID, actorID uint32, appType GuildApplicationType, mailSenderID, mailRecipientID uint32, mailSubject, mailBody string) error
CancelInvitation(guildID, charID uint32) error
CreateInviteWithMail(guildID, charID, actorID uint32, mailSenderID, mailRecipientID uint32, mailSubject, mailBody string) error
HasInvite(guildID, charID uint32) (bool, error)
CancelInvite(inviteID uint32) error
AcceptInvite(guildID, charID uint32) error
DeclineInvite(guildID, charID uint32) error
RejectApplication(guildID, charID uint32) error
ArrangeCharacters(charIDs []uint32) error
GetApplication(guildID, charID uint32, appType GuildApplicationType) (*GuildApplication, error)
@@ -120,7 +123,7 @@ type GuildRepo interface {
CountGuildKills(guildID, charID uint32) (int, error)
ClearTreasureHunt(charID uint32) error
InsertKillLog(charID uint32, monster int, quantity uint8, timestamp time.Time) error
ListInvitedCharacters(guildID uint32) ([]*ScoutedCharacter, error)
ListInvites(guildID uint32) ([]*GuildInvite, error)
RolloverDailyRP(guildID uint32, noon time.Time) error
AddWeeklyBonusUsers(guildID uint32, numUsers uint8) error
}

View File

@@ -308,8 +308,10 @@ type mockGuildRepo struct {
removeErr error
createAppErr error
getMemberErr error
hasAppResult bool
hasAppErr error
hasAppResult bool
hasAppErr error
hasInviteResult bool
hasInviteErr error
listPostsErr error
createPostErr error
deletePostErr error
@@ -317,8 +319,10 @@ type mockGuildRepo struct {
// State tracking
disbandedID uint32
removedCharID uint32
acceptedCharID uint32
rejectedCharID uint32
acceptedCharID uint32
rejectedCharID uint32
acceptInviteCharID uint32
declineInviteCharID uint32
savedGuild *Guild
savedMembers []*GuildMember
createdAppArgs []interface{}
@@ -571,10 +575,19 @@ func (m *mockGuildRepo) CountGuildKills(_, _ uint32) (int, error) {
// No-op stubs for remaining GuildRepo interface methods.
func (m *mockGuildRepo) ListAll() ([]*Guild, error) { return nil, nil }
func (m *mockGuildRepo) Create(_ uint32, _ string) (int32, error) { return 0, nil }
func (m *mockGuildRepo) CreateApplicationWithMail(_, _, _ uint32, _ GuildApplicationType, _, _ uint32, _, _ string) error {
return nil
func (m *mockGuildRepo) CreateInviteWithMail(_, _, _, _, _ uint32, _, _ string) error { return nil }
func (m *mockGuildRepo) HasInvite(_, _ uint32) (bool, error) {
return m.hasInviteResult, m.hasInviteErr
}
func (m *mockGuildRepo) CancelInvite(_ uint32) error { return nil }
func (m *mockGuildRepo) AcceptInvite(_, charID uint32) error {
m.acceptInviteCharID = charID
return m.acceptErr
}
func (m *mockGuildRepo) DeclineInvite(_, charID uint32) error {
m.declineInviteCharID = charID
return m.rejectErr
}
func (m *mockGuildRepo) CancelInvitation(_, _ uint32) error { return nil }
func (m *mockGuildRepo) ArrangeCharacters(_ []uint32) error { return nil }
func (m *mockGuildRepo) GetItemBox(_ uint32) ([]byte, error) { return nil, nil }
func (m *mockGuildRepo) SaveItemBox(_ uint32, _ []byte) error { return nil }
@@ -597,9 +610,7 @@ func (m *mockGuildRepo) CountNewPosts(_ uint32, _ time.Time) (int, error)
func (m *mockGuildRepo) ListAlliances() ([]*GuildAlliance, error) { return nil, nil }
func (m *mockGuildRepo) ClearTreasureHunt(_ uint32) error { return nil }
func (m *mockGuildRepo) InsertKillLog(_ uint32, _ int, _ uint8, _ time.Time) error { return nil }
func (m *mockGuildRepo) ListInvitedCharacters(_ uint32) ([]*ScoutedCharacter, error) {
return nil, nil
}
func (m *mockGuildRepo) ListInvites(_ uint32) ([]*GuildInvite, error) { return nil, nil }
func (m *mockGuildRepo) RolloverDailyRP(_ uint32, _ time.Time) error { return nil }
func (m *mockGuildRepo) AddWeeklyBonusUsers(_ uint32, _ uint8) error { return nil }

View File

@@ -280,16 +280,16 @@ func (svc *GuildService) PostScout(actorCharID, targetCharID uint32, strings Sco
return fmt.Errorf("guild lookup: %w", err)
}
hasApp, err := svc.guildRepo.HasApplication(guild.ID, targetCharID)
hasInvite, err := svc.guildRepo.HasInvite(guild.ID, targetCharID)
if err != nil {
return fmt.Errorf("check application: %w", err)
return fmt.Errorf("check invite: %w", err)
}
if hasApp {
if hasInvite {
return ErrAlreadyInvited
}
err = svc.guildRepo.CreateApplicationWithMail(
guild.ID, targetCharID, actorCharID, GuildApplicationTypeInvited,
err = svc.guildRepo.CreateInviteWithMail(
guild.ID, targetCharID, actorCharID,
actorCharID, targetCharID,
strings.Title,
fmt.Sprintf(strings.Body, guild.Name))
@@ -309,8 +309,8 @@ func (svc *GuildService) AnswerScout(charID, leaderID uint32, accept bool, strin
return nil, fmt.Errorf("guild lookup for leader %d: %w", leaderID, err)
}
app, err := svc.guildRepo.GetApplication(guild.ID, charID, GuildApplicationTypeInvited)
if app == nil || err != nil {
hasInvite, err := svc.guildRepo.HasInvite(guild.ID, charID)
if err != nil || !hasInvite {
return &AnswerScoutResult{
GuildID: guild.ID,
Success: false,
@@ -319,13 +319,13 @@ func (svc *GuildService) AnswerScout(charID, leaderID uint32, accept bool, strin
var mails []Mail
if accept {
err = svc.guildRepo.AcceptApplication(guild.ID, charID)
err = svc.guildRepo.AcceptInvite(guild.ID, charID)
mails = []Mail{
{SenderID: 0, RecipientID: charID, Subject: strings.SuccessTitle, Body: fmt.Sprintf(strings.SuccessBody, guild.Name), IsSystemMessage: true},
{SenderID: charID, RecipientID: leaderID, Subject: strings.AcceptedTitle, Body: fmt.Sprintf(strings.AcceptedBody, guild.Name), IsSystemMessage: true},
}
} else {
err = svc.guildRepo.RejectApplication(guild.ID, charID)
err = svc.guildRepo.DeclineInvite(guild.ID, charID)
mails = []Mail{
{SenderID: 0, RecipientID: charID, Subject: strings.RejectedTitle, Body: fmt.Sprintf(strings.RejectedBody, guild.Name), IsSystemMessage: true},
{SenderID: charID, RecipientID: leaderID, Subject: strings.DeclinedTitle, Body: fmt.Sprintf(strings.DeclinedBody, guild.Name), IsSystemMessage: true},

View File

@@ -385,14 +385,14 @@ func TestGuildService_PostScout(t *testing.T) {
strings := ScoutInviteStrings{Title: "Invite", Body: "Join 「%s」"}
tests := []struct {
name string
membership *GuildMember
guild *Guild
hasApp bool
hasAppErr error
createAppErr error
getMemberErr error
wantErr error
name string
membership *GuildMember
guild *Guild
hasInvite bool
hasInviteErr error
createAppErr error
getMemberErr error
wantErr error
}{
{
name: "successful scout",
@@ -403,7 +403,7 @@ func TestGuildService_PostScout(t *testing.T) {
name: "already invited",
membership: &GuildMember{GuildID: 10, CharID: 1, IsLeader: true, OrderIndex: 1},
guild: &Guild{ID: 10, Name: "TestGuild"},
hasApp: true,
hasInvite: true,
wantErr: ErrAlreadyInvited,
},
{
@@ -423,11 +423,11 @@ func TestGuildService_PostScout(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guildMock := &mockGuildRepo{
membership: tt.membership,
hasAppResult: tt.hasApp,
hasAppErr: tt.hasAppErr,
createAppErr: tt.createAppErr,
getMemberErr: tt.getMemberErr,
membership: tt.membership,
hasInviteResult: tt.hasInvite,
hasInviteErr: tt.hasInviteErr,
createAppErr: tt.createAppErr,
getMemberErr: tt.getMemberErr,
}
guildMock.guild = tt.guild
svc := newTestGuildService(guildMock, &mockMailRepo{})
@@ -468,7 +468,7 @@ func TestGuildService_AnswerScout(t *testing.T) {
name string
accept bool
guild *Guild
application *GuildApplication
hasInvite bool
acceptErr error
rejectErr error
sendErr error
@@ -477,13 +477,13 @@ func TestGuildService_AnswerScout(t *testing.T) {
wantErr error
wantMailCount int
wantAccepted uint32
wantRejected uint32
wantDeclined uint32
}{
{
name: "accept invitation",
accept: true,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: &GuildApplication{GuildID: 10, CharID: 1},
hasInvite: true,
wantSuccess: true,
wantMailCount: 2,
wantAccepted: 1,
@@ -492,16 +492,16 @@ func TestGuildService_AnswerScout(t *testing.T) {
name: "decline invitation",
accept: false,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: &GuildApplication{GuildID: 10, CharID: 1},
hasInvite: true,
wantSuccess: true,
wantMailCount: 2,
wantRejected: 1,
wantDeclined: 1,
},
{
name: "application missing",
accept: true,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: nil,
hasInvite: false,
wantSuccess: false,
wantErr: ErrApplicationMissing,
},
@@ -516,7 +516,7 @@ func TestGuildService_AnswerScout(t *testing.T) {
name: "mail error is best-effort",
accept: true,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: &GuildApplication{GuildID: 10, CharID: 1},
hasInvite: true,
sendErr: errors.New("mail failed"),
wantSuccess: true,
wantMailCount: 2,
@@ -527,9 +527,9 @@ func TestGuildService_AnswerScout(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guildMock := &mockGuildRepo{
application: tt.application,
acceptErr: tt.acceptErr,
rejectErr: tt.rejectErr,
hasInviteResult: tt.hasInvite,
acceptErr: tt.acceptErr,
rejectErr: tt.rejectErr,
}
guildMock.guild = tt.guild
guildMock.getErr = tt.getErr
@@ -559,11 +559,11 @@ func TestGuildService_AnswerScout(t *testing.T) {
if len(mailMock.sentMails) != tt.wantMailCount {
t.Errorf("sentMails count = %d, want %d", len(mailMock.sentMails), tt.wantMailCount)
}
if tt.wantAccepted != 0 && guildMock.acceptedCharID != tt.wantAccepted {
t.Errorf("acceptedCharID = %d, want %d", guildMock.acceptedCharID, tt.wantAccepted)
if tt.wantAccepted != 0 && guildMock.acceptInviteCharID != tt.wantAccepted {
t.Errorf("acceptInviteCharID = %d, want %d", guildMock.acceptInviteCharID, tt.wantAccepted)
}
if tt.wantRejected != 0 && guildMock.rejectedCharID != tt.wantRejected {
t.Errorf("rejectedCharID = %d, want %d", guildMock.rejectedCharID, tt.wantRejected)
if tt.wantDeclined != 0 && guildMock.declineInviteCharID != tt.wantDeclined {
t.Errorf("declineInviteCharID = %d, want %d", guildMock.declineInviteCharID, tt.wantDeclined)
}
})
}

View File

@@ -0,0 +1,23 @@
BEGIN;
-- Dedicated table for guild-initiated scout invitations, separate from
-- player-initiated applications. This gives each invitation a real serial PK
-- so the client's InvitationID field can map to an actual database row
-- instead of being aliased to the character ID.
CREATE TABLE guild_invites (
id serial PRIMARY KEY,
guild_id integer REFERENCES guilds(id),
character_id integer REFERENCES characters(id),
actor_id integer REFERENCES characters(id),
created_at timestamptz NOT NULL DEFAULT now()
);
-- Migrate any existing scout invitations from guild_applications.
INSERT INTO guild_invites (guild_id, character_id, actor_id, created_at)
SELECT guild_id, character_id, actor_id, COALESCE(created_at, now())
FROM guild_applications
WHERE application_type = 'invited';
DELETE FROM guild_applications WHERE application_type = 'invited';
COMMIT;