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 ### 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). - 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). - 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)). - 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 return
} }
err = s.server.guildRepo.CancelInvitation(guild.ID, pkt.InvitationID) err = s.server.guildRepo.CancelInvite(pkt.InvitationID)
if err != nil { if err != nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4)) 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 { 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)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
return return
} }
bf := byteframe.NewByteFrame() bf := byteframe.NewByteFrame()
bf.SetBE() bf.SetBE()
bf.WriteUint32(uint32(len(chars))) bf.WriteUint32(uint32(len(invites)))
for _, sc := range chars { for _, inv := range invites {
// This seems to be used as a unique ID for the invitation sent bf.WriteUint32(inv.ID)
// we can just use the charID and then filter on guild_id+charID when performing operations bf.WriteUint32(inv.ActorID)
// this might be a problem later with mails sent referencing IDs but we'll see. bf.WriteUint32(inv.CharID)
bf.WriteUint32(sc.CharID) bf.WriteUint32(uint32(inv.InvitedAt.Unix()))
bf.WriteUint32(sc.ActorID) bf.WriteUint16(inv.HR)
bf.WriteUint32(sc.CharID) bf.WriteUint16(inv.GR)
bf.WriteUint32(uint32(TimeAdjusted().Unix())) bf.WriteBytes(stringsupport.PaddedString(inv.Name, 32, true))
bf.WriteUint16(sc.HR)
bf.WriteUint16(sc.GR)
bf.WriteBytes(stringsupport.PaddedString(sc.Name, 32, true))
} }
doAckBufSucceed(s, pkt.AckHandle, bf.Data()) doAckBufSucceed(s, pkt.AckHandle, bf.Data())

View File

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

View File

@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
@@ -270,8 +271,9 @@ func (r *GuildRepository) CreateApplication(guildID, charID, actorID uint32, app
return err return err
} }
// CreateApplicationWithMail atomically creates an application and sends a notification mail. // CreateInviteWithMail atomically inserts a scout invitation into guild_invites
func (r *GuildRepository) CreateApplicationWithMail(guildID, charID, actorID uint32, appType GuildApplicationType, mailSenderID, mailRecipientID uint32, mailSubject, mailBody string) error { // 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) tx, err := r.db.BeginTxx(context.Background(), nil)
if err != nil { if err != nil {
return err return err
@@ -279,8 +281,8 @@ func (r *GuildRepository) CreateApplicationWithMail(guildID, charID, actorID uin
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec( if _, err := tx.Exec(
`INSERT INTO guild_applications (guild_id, character_id, actor_id, application_type) VALUES ($1, $2, $3, $4)`, `INSERT INTO guild_invites (guild_id, character_id, actor_id) VALUES ($1, $2, $3)`,
guildID, charID, actorID, appType); err != nil { guildID, charID, actorID); err != nil {
return err return err
} }
if _, err := tx.Exec(mailInsertQuery, mailSenderID, mailRecipientID, mailSubject, mailBody, 0, 0, true, false); err != nil { 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() return tx.Commit()
} }
// CancelInvitation removes an invitation for a character. // HasInvite reports whether a pending scout invitation exists for the character in the guild.
func (r *GuildRepository) CancelInvitation(guildID, charID uint32) error { 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( _, err := r.db.Exec(
`DELETE FROM guild_applications WHERE character_id = $1 AND guild_id = $2 AND application_type = 'invited'`, `DELETE FROM guild_invites WHERE guild_id = $1 AND character_id = $2`,
charID, guildID, guildID, charID,
) )
return err return err
} }
@@ -433,34 +479,39 @@ func (r *GuildRepository) SetRecruiter(charID uint32, allowed bool) error {
return err return err
} }
// ScoutedCharacter represents an invited character in the scout list. // GuildInvite represents a pending scout invitation with the target character's info.
type ScoutedCharacter struct { type GuildInvite struct {
CharID uint32 `db:"id"` ID uint32 `db:"id"`
Name string `db:"name"` GuildID uint32 `db:"guild_id"`
HR uint16 `db:"hr"` CharID uint32 `db:"character_id"`
GR uint16 `db:"gr"` ActorID uint32 `db:"actor_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. // ListInvites returns all pending scout invitations for a guild, including
func (r *GuildRepository) ListInvitedCharacters(guildID uint32) ([]*ScoutedCharacter, error) { // the target character's HR, GR, and name.
func (r *GuildRepository) ListInvites(guildID uint32) ([]*GuildInvite, error) {
rows, err := r.db.Queryx(` rows, err := r.db.Queryx(`
SELECT c.id, c.name, c.hr, c.gr, ga.actor_id SELECT gi.id, gi.guild_id, gi.character_id, gi.actor_id, gi.created_at,
FROM guild_applications ga c.hr, c.gr, c.name
JOIN characters c ON c.id = ga.character_id FROM guild_invites gi
WHERE ga.guild_id = $1 AND ga.application_type = 'invited' JOIN characters c ON c.id = gi.character_id
WHERE gi.guild_id = $1
`, guildID) `, guildID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
var chars []*ScoutedCharacter var invites []*GuildInvite
for rows.Next() { for rows.Next() {
sc := &ScoutedCharacter{} inv := &GuildInvite{}
if err := rows.StructScan(sc); err != nil { if err := rows.StructScan(inv); err != nil {
continue 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 --- // --- Invitation / Scout tests ---
func TestCancelInvitation(t *testing.T) { func TestCancelInvite(t *testing.T) {
repo, db, guildID, leaderID := setupGuildRepo(t) repo, db, guildID, leaderID := setupGuildRepo(t)
user2 := CreateTestUser(t, db, "invite_user") user2 := CreateTestUser(t, db, "invite_user")
char2 := CreateTestCharacter(t, db, user2, "Invited") char2 := CreateTestCharacter(t, db, user2, "Invited")
if err := repo.CreateApplication(guildID, char2, leaderID, GuildApplicationTypeInvited); err != nil { if err := repo.CreateInviteWithMail(guildID, char2, leaderID, leaderID, char2, "Invite", "body"); err != nil {
t.Fatalf("CreateApplication (invited) failed: %v", err) t.Fatalf("CreateInviteWithMail failed: %v", err)
} }
if err := repo.CancelInvitation(guildID, char2); err != nil { invites, err := repo.ListInvites(guildID)
t.Fatalf("CancelInvitation failed: %v", err) 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 { if err != nil {
t.Fatalf("HasApplication failed: %v", err) t.Fatalf("HasInvite failed: %v", err)
} }
if has { 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) repo, db, guildID, leaderID := setupGuildRepo(t)
user2 := CreateTestUser(t, db, "scout_user") user2 := CreateTestUser(t, db, "scout_user")
char2 := CreateTestCharacter(t, db, user2, "Scouted") char2 := CreateTestCharacter(t, db, user2, "Scouted")
if err := repo.CreateApplication(guildID, char2, leaderID, GuildApplicationTypeInvited); err != nil { if err := repo.CreateInviteWithMail(guildID, char2, leaderID, leaderID, char2, "Invite", "body"); err != nil {
t.Fatalf("CreateApplication failed: %v", err) t.Fatalf("CreateInviteWithMail failed: %v", err)
} }
chars, err := repo.ListInvitedCharacters(guildID) invites, err := repo.ListInvites(guildID)
if err != nil { if err != nil {
t.Fatalf("ListInvitedCharacters failed: %v", err) t.Fatalf("ListInvites failed: %v", err)
} }
if len(chars) != 1 { if len(invites) != 1 {
t.Fatalf("Expected 1 invited character, got %d", len(chars)) t.Fatalf("Expected 1 invite, got %d", len(invites))
} }
if chars[0].CharID != char2 { if invites[0].CharID != char2 {
t.Errorf("Expected char ID %d, got %d", char2, chars[0].CharID) t.Errorf("Expected char ID %d, got %d", char2, invites[0].CharID)
} }
if chars[0].Name != "Scouted" { if invites[0].Name != "Scouted" {
t.Errorf("Expected name 'Scouted', got %q", chars[0].Name) t.Errorf("Expected name 'Scouted', got %q", invites[0].Name)
} }
if chars[0].ActorID != leaderID { if invites[0].ActorID != leaderID {
t.Errorf("Expected actor ID %d, got %d", leaderID, chars[0].ActorID) 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) repo, _, guildID, _ := setupGuildRepo(t)
chars, err := repo.ListInvitedCharacters(guildID) invites, err := repo.ListInvites(guildID)
if err != nil { if err != nil {
t.Fatalf("ListInvitedCharacters failed: %v", err) t.Fatalf("ListInvites failed: %v", err)
} }
if len(chars) != 0 { if len(invites) != 0 {
t.Errorf("Expected 0 invited characters, got %d", len(chars)) 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) repo, db, guildID, leaderID := setupGuildRepo(t)
user2 := CreateTestUser(t, db, "scout_mail_user") user2 := CreateTestUser(t, db, "scout_mail_user")
char2 := CreateTestCharacter(t, db, user2, "ScoutTarget") char2 := CreateTestCharacter(t, db, user2, "ScoutTarget")
err := repo.CreateApplicationWithMail( err := repo.CreateInviteWithMail(guildID, char2, leaderID, leaderID, char2, "Guild Invite", "You have been invited!")
guildID, char2, leaderID, GuildApplicationTypeInvited,
leaderID, char2, "Guild Invite", "You have been invited!")
if err != nil { if err != nil {
t.Fatalf("CreateApplicationWithMail failed: %v", err) t.Fatalf("CreateInviteWithMail failed: %v", err)
} }
// Verify application was created // Verify invite was created
has, err := repo.HasApplication(guildID, char2) has, err := repo.HasInvite(guildID, char2)
if err != nil { if err != nil {
t.Fatalf("HasApplication failed: %v", err) t.Fatalf("HasInvite failed: %v", err)
} }
if !has { if !has {
t.Error("Expected application to exist after CreateApplicationWithMail") t.Error("Expected invite to exist after CreateInviteWithMail")
} }
// Verify mail was sent // Verify mail was sent

View File

@@ -64,8 +64,11 @@ type GuildRepo interface {
RemoveCharacter(charID uint32) error RemoveCharacter(charID uint32) error
AcceptApplication(guildID, charID uint32) error AcceptApplication(guildID, charID uint32) error
CreateApplication(guildID, charID, actorID uint32, appType GuildApplicationType) error CreateApplication(guildID, charID, actorID uint32, appType GuildApplicationType) error
CreateApplicationWithMail(guildID, charID, actorID uint32, appType GuildApplicationType, mailSenderID, mailRecipientID uint32, mailSubject, mailBody string) error CreateInviteWithMail(guildID, charID, actorID uint32, mailSenderID, mailRecipientID uint32, mailSubject, mailBody string) error
CancelInvitation(guildID, charID uint32) 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 RejectApplication(guildID, charID uint32) error
ArrangeCharacters(charIDs []uint32) error ArrangeCharacters(charIDs []uint32) error
GetApplication(guildID, charID uint32, appType GuildApplicationType) (*GuildApplication, error) GetApplication(guildID, charID uint32, appType GuildApplicationType) (*GuildApplication, error)
@@ -120,7 +123,7 @@ type GuildRepo interface {
CountGuildKills(guildID, charID uint32) (int, error) CountGuildKills(guildID, charID uint32) (int, error)
ClearTreasureHunt(charID uint32) error ClearTreasureHunt(charID uint32) error
InsertKillLog(charID uint32, monster int, quantity uint8, timestamp time.Time) 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 RolloverDailyRP(guildID uint32, noon time.Time) error
AddWeeklyBonusUsers(guildID uint32, numUsers uint8) error AddWeeklyBonusUsers(guildID uint32, numUsers uint8) error
} }

View File

@@ -308,8 +308,10 @@ type mockGuildRepo struct {
removeErr error removeErr error
createAppErr error createAppErr error
getMemberErr error getMemberErr error
hasAppResult bool hasAppResult bool
hasAppErr error hasAppErr error
hasInviteResult bool
hasInviteErr error
listPostsErr error listPostsErr error
createPostErr error createPostErr error
deletePostErr error deletePostErr error
@@ -317,8 +319,10 @@ type mockGuildRepo struct {
// State tracking // State tracking
disbandedID uint32 disbandedID uint32
removedCharID uint32 removedCharID uint32
acceptedCharID uint32 acceptedCharID uint32
rejectedCharID uint32 rejectedCharID uint32
acceptInviteCharID uint32
declineInviteCharID uint32
savedGuild *Guild savedGuild *Guild
savedMembers []*GuildMember savedMembers []*GuildMember
createdAppArgs []interface{} createdAppArgs []interface{}
@@ -571,10 +575,19 @@ func (m *mockGuildRepo) CountGuildKills(_, _ uint32) (int, error) {
// No-op stubs for remaining GuildRepo interface methods. // No-op stubs for remaining GuildRepo interface methods.
func (m *mockGuildRepo) ListAll() ([]*Guild, error) { return nil, nil } func (m *mockGuildRepo) ListAll() ([]*Guild, error) { return nil, nil }
func (m *mockGuildRepo) Create(_ uint32, _ string) (int32, error) { return 0, nil } func (m *mockGuildRepo) Create(_ uint32, _ string) (int32, error) { return 0, nil }
func (m *mockGuildRepo) CreateApplicationWithMail(_, _, _ uint32, _ GuildApplicationType, _, _ uint32, _, _ string) error { func (m *mockGuildRepo) CreateInviteWithMail(_, _, _, _, _ uint32, _, _ string) error { return nil }
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) ArrangeCharacters(_ []uint32) error { return nil }
func (m *mockGuildRepo) GetItemBox(_ uint32) ([]byte, error) { return nil, nil } func (m *mockGuildRepo) GetItemBox(_ uint32) ([]byte, error) { return nil, nil }
func (m *mockGuildRepo) SaveItemBox(_ uint32, _ []byte) error { return 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) ListAlliances() ([]*GuildAlliance, error) { return nil, nil }
func (m *mockGuildRepo) ClearTreasureHunt(_ uint32) error { return nil } func (m *mockGuildRepo) ClearTreasureHunt(_ uint32) error { return nil }
func (m *mockGuildRepo) InsertKillLog(_ uint32, _ int, _ uint8, _ time.Time) error { return nil } func (m *mockGuildRepo) InsertKillLog(_ uint32, _ int, _ uint8, _ time.Time) error { return nil }
func (m *mockGuildRepo) ListInvitedCharacters(_ uint32) ([]*ScoutedCharacter, error) { func (m *mockGuildRepo) ListInvites(_ uint32) ([]*GuildInvite, error) { return nil, nil }
return nil, nil
}
func (m *mockGuildRepo) RolloverDailyRP(_ uint32, _ time.Time) error { return nil } func (m *mockGuildRepo) RolloverDailyRP(_ uint32, _ time.Time) error { return nil }
func (m *mockGuildRepo) AddWeeklyBonusUsers(_ uint32, _ uint8) 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) 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 { if err != nil {
return fmt.Errorf("check application: %w", err) return fmt.Errorf("check invite: %w", err)
} }
if hasApp { if hasInvite {
return ErrAlreadyInvited return ErrAlreadyInvited
} }
err = svc.guildRepo.CreateApplicationWithMail( err = svc.guildRepo.CreateInviteWithMail(
guild.ID, targetCharID, actorCharID, GuildApplicationTypeInvited, guild.ID, targetCharID, actorCharID,
actorCharID, targetCharID, actorCharID, targetCharID,
strings.Title, strings.Title,
fmt.Sprintf(strings.Body, guild.Name)) 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) return nil, fmt.Errorf("guild lookup for leader %d: %w", leaderID, err)
} }
app, err := svc.guildRepo.GetApplication(guild.ID, charID, GuildApplicationTypeInvited) hasInvite, err := svc.guildRepo.HasInvite(guild.ID, charID)
if app == nil || err != nil { if err != nil || !hasInvite {
return &AnswerScoutResult{ return &AnswerScoutResult{
GuildID: guild.ID, GuildID: guild.ID,
Success: false, Success: false,
@@ -319,13 +319,13 @@ func (svc *GuildService) AnswerScout(charID, leaderID uint32, accept bool, strin
var mails []Mail var mails []Mail
if accept { if accept {
err = svc.guildRepo.AcceptApplication(guild.ID, charID) err = svc.guildRepo.AcceptInvite(guild.ID, charID)
mails = []Mail{ mails = []Mail{
{SenderID: 0, RecipientID: charID, Subject: strings.SuccessTitle, Body: fmt.Sprintf(strings.SuccessBody, guild.Name), IsSystemMessage: true}, {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}, {SenderID: charID, RecipientID: leaderID, Subject: strings.AcceptedTitle, Body: fmt.Sprintf(strings.AcceptedBody, guild.Name), IsSystemMessage: true},
} }
} else { } else {
err = svc.guildRepo.RejectApplication(guild.ID, charID) err = svc.guildRepo.DeclineInvite(guild.ID, charID)
mails = []Mail{ mails = []Mail{
{SenderID: 0, RecipientID: charID, Subject: strings.RejectedTitle, Body: fmt.Sprintf(strings.RejectedBody, guild.Name), IsSystemMessage: true}, {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}, {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」"} strings := ScoutInviteStrings{Title: "Invite", Body: "Join 「%s」"}
tests := []struct { tests := []struct {
name string name string
membership *GuildMember membership *GuildMember
guild *Guild guild *Guild
hasApp bool hasInvite bool
hasAppErr error hasInviteErr error
createAppErr error createAppErr error
getMemberErr error getMemberErr error
wantErr error wantErr error
}{ }{
{ {
name: "successful scout", name: "successful scout",
@@ -403,7 +403,7 @@ func TestGuildService_PostScout(t *testing.T) {
name: "already invited", name: "already invited",
membership: &GuildMember{GuildID: 10, CharID: 1, IsLeader: true, OrderIndex: 1}, membership: &GuildMember{GuildID: 10, CharID: 1, IsLeader: true, OrderIndex: 1},
guild: &Guild{ID: 10, Name: "TestGuild"}, guild: &Guild{ID: 10, Name: "TestGuild"},
hasApp: true, hasInvite: true,
wantErr: ErrAlreadyInvited, wantErr: ErrAlreadyInvited,
}, },
{ {
@@ -423,11 +423,11 @@ func TestGuildService_PostScout(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
guildMock := &mockGuildRepo{ guildMock := &mockGuildRepo{
membership: tt.membership, membership: tt.membership,
hasAppResult: tt.hasApp, hasInviteResult: tt.hasInvite,
hasAppErr: tt.hasAppErr, hasInviteErr: tt.hasInviteErr,
createAppErr: tt.createAppErr, createAppErr: tt.createAppErr,
getMemberErr: tt.getMemberErr, getMemberErr: tt.getMemberErr,
} }
guildMock.guild = tt.guild guildMock.guild = tt.guild
svc := newTestGuildService(guildMock, &mockMailRepo{}) svc := newTestGuildService(guildMock, &mockMailRepo{})
@@ -468,7 +468,7 @@ func TestGuildService_AnswerScout(t *testing.T) {
name string name string
accept bool accept bool
guild *Guild guild *Guild
application *GuildApplication hasInvite bool
acceptErr error acceptErr error
rejectErr error rejectErr error
sendErr error sendErr error
@@ -477,13 +477,13 @@ func TestGuildService_AnswerScout(t *testing.T) {
wantErr error wantErr error
wantMailCount int wantMailCount int
wantAccepted uint32 wantAccepted uint32
wantRejected uint32 wantDeclined uint32
}{ }{
{ {
name: "accept invitation", name: "accept invitation",
accept: true, accept: true,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}}, guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: &GuildApplication{GuildID: 10, CharID: 1}, hasInvite: true,
wantSuccess: true, wantSuccess: true,
wantMailCount: 2, wantMailCount: 2,
wantAccepted: 1, wantAccepted: 1,
@@ -492,16 +492,16 @@ func TestGuildService_AnswerScout(t *testing.T) {
name: "decline invitation", name: "decline invitation",
accept: false, accept: false,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}}, guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: &GuildApplication{GuildID: 10, CharID: 1}, hasInvite: true,
wantSuccess: true, wantSuccess: true,
wantMailCount: 2, wantMailCount: 2,
wantRejected: 1, wantDeclined: 1,
}, },
{ {
name: "application missing", name: "application missing",
accept: true, accept: true,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}}, guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: nil, hasInvite: false,
wantSuccess: false, wantSuccess: false,
wantErr: ErrApplicationMissing, wantErr: ErrApplicationMissing,
}, },
@@ -516,7 +516,7 @@ func TestGuildService_AnswerScout(t *testing.T) {
name: "mail error is best-effort", name: "mail error is best-effort",
accept: true, accept: true,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}}, guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: &GuildApplication{GuildID: 10, CharID: 1}, hasInvite: true,
sendErr: errors.New("mail failed"), sendErr: errors.New("mail failed"),
wantSuccess: true, wantSuccess: true,
wantMailCount: 2, wantMailCount: 2,
@@ -527,9 +527,9 @@ func TestGuildService_AnswerScout(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
guildMock := &mockGuildRepo{ guildMock := &mockGuildRepo{
application: tt.application, hasInviteResult: tt.hasInvite,
acceptErr: tt.acceptErr, acceptErr: tt.acceptErr,
rejectErr: tt.rejectErr, rejectErr: tt.rejectErr,
} }
guildMock.guild = tt.guild guildMock.guild = tt.guild
guildMock.getErr = tt.getErr guildMock.getErr = tt.getErr
@@ -559,11 +559,11 @@ func TestGuildService_AnswerScout(t *testing.T) {
if len(mailMock.sentMails) != tt.wantMailCount { if len(mailMock.sentMails) != tt.wantMailCount {
t.Errorf("sentMails count = %d, want %d", 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 { if tt.wantAccepted != 0 && guildMock.acceptInviteCharID != tt.wantAccepted {
t.Errorf("acceptedCharID = %d, want %d", guildMock.acceptedCharID, tt.wantAccepted) t.Errorf("acceptInviteCharID = %d, want %d", guildMock.acceptInviteCharID, tt.wantAccepted)
} }
if tt.wantRejected != 0 && guildMock.rejectedCharID != tt.wantRejected { if tt.wantDeclined != 0 && guildMock.declineInviteCharID != tt.wantDeclined {
t.Errorf("rejectedCharID = %d, want %d", guildMock.rejectedCharID, tt.wantRejected) 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;