mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 15:43:49 +01:00
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:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
23
server/migrations/sql/0012_guild_invites.sql
Normal file
23
server/migrations/sql/0012_guild_invites.sql
Normal 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;
|
||||
Reference in New Issue
Block a user