diff --git a/CHANGELOG.md b/CHANGELOG.md index 27bb5c3fd..d2cc346da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/server/channelserver/handlers_guild_scout.go b/server/channelserver/handlers_guild_scout.go index 8e390afb4..ab5ea8042 100644 --- a/server/channelserver/handlers_guild_scout.go +++ b/server/channelserver/handlers_guild_scout.go @@ -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()) diff --git a/server/channelserver/handlers_guild_scout_test.go b/server/channelserver/handlers_guild_scout_test.go index f41973a05..f601a5264 100644 --- a/server/channelserver/handlers_guild_scout_test.go +++ b/server/channelserver/handlers_guild_scout_test.go @@ -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 diff --git a/server/channelserver/repo_guild.go b/server/channelserver/repo_guild.go index f9eca11e5..ae17645f9 100644 --- a/server/channelserver/repo_guild.go +++ b/server/channelserver/repo_guild.go @@ -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 } diff --git a/server/channelserver/repo_guild_test.go b/server/channelserver/repo_guild_test.go index fc2ff6c3c..d239739d2 100644 --- a/server/channelserver/repo_guild_test.go +++ b/server/channelserver/repo_guild_test.go @@ -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 diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index 6f1fb83cb..4d5595673 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -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 } diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index 595d5e973..9e03a67c8 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -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 } diff --git a/server/channelserver/svc_guild.go b/server/channelserver/svc_guild.go index 3ea5268ad..fd45c33dc 100644 --- a/server/channelserver/svc_guild.go +++ b/server/channelserver/svc_guild.go @@ -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}, diff --git a/server/channelserver/svc_guild_test.go b/server/channelserver/svc_guild_test.go index 3b7afd2f0..f84f5aa87 100644 --- a/server/channelserver/svc_guild_test.go +++ b/server/channelserver/svc_guild_test.go @@ -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) } }) } diff --git a/server/migrations/sql/0012_guild_invites.sql b/server/migrations/sql/0012_guild_invites.sql new file mode 100644 index 000000000..c013686a4 --- /dev/null +++ b/server/migrations/sql/0012_guild_invites.sql @@ -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;