diff --git a/server/channelserver/handlers_guild_ops.go b/server/channelserver/handlers_guild_ops.go index b3a928cfa..5cc30e594 100644 --- a/server/channelserver/handlers_guild_ops.go +++ b/server/channelserver/handlers_guild_ops.go @@ -60,7 +60,7 @@ func handleMsgMhfOperateGuild(s *Session, p mhfpacket.MHFPacket) { _ = s.server.guildRepo.Save(guild) } case mhfpacket.OperateGuildApply: - err = s.server.guildRepo.CreateApplication(guild.ID, s.charID, s.charID, GuildApplicationTypeApplied, nil) + err = s.server.guildRepo.CreateApplication(guild.ID, s.charID, s.charID, GuildApplicationTypeApplied) if err == nil { bf.WriteUint32(guild.LeaderCharID) } else { diff --git a/server/channelserver/handlers_guild_scout.go b/server/channelserver/handlers_guild_scout.go index 8f3c4eb44..b0bb1a0b6 100644 --- a/server/channelserver/handlers_guild_scout.go +++ b/server/channelserver/handlers_guild_scout.go @@ -45,38 +45,14 @@ func handleMsgMhfPostGuildScout(s *Session, p mhfpacket.MHFPacket) { return } - transaction, err := s.server.db.Begin() - - if err != nil { - s.logger.Error("Failed to begin transaction for guild scout", zap.Error(err)) - doAckBufFail(s, pkt.AckHandle, nil) - return - } - - err = s.server.guildRepo.CreateApplication(guildInfo.ID, pkt.CharID, s.charID, GuildApplicationTypeInvited, transaction) - - if err != nil { - _ = transaction.Rollback() - s.logger.Error("Failed to create guild scout application", zap.Error(err)) - doAckBufFail(s, pkt.AckHandle, nil) - return - } - - err = s.server.mailRepo.SendMailTx(transaction, s.charID, pkt.CharID, + err = s.server.guildRepo.CreateApplicationWithMail( + guildInfo.ID, pkt.CharID, s.charID, GuildApplicationTypeInvited, + s.charID, pkt.CharID, s.server.i18n.guild.invite.title, - fmt.Sprintf(s.server.i18n.guild.invite.body, guildInfo.Name), - 0, 0, true, false) + fmt.Sprintf(s.server.i18n.guild.invite.body, guildInfo.Name)) if err != nil { - _ = transaction.Rollback() - doAckBufFail(s, pkt.AckHandle, nil) - return - } - - err = transaction.Commit() - - if err != nil { - s.logger.Error("Failed to commit guild scout transaction", zap.Error(err)) + s.logger.Error("Failed to create guild scout application with mail", zap.Error(err)) doAckBufFail(s, pkt.AckHandle, nil) return } diff --git a/server/channelserver/handlers_quest.go b/server/channelserver/handlers_quest.go index 10e6611de..1443b77da 100644 --- a/server/channelserver/handlers_quest.go +++ b/server/channelserver/handlers_quest.go @@ -342,12 +342,7 @@ func handleMsgMhfEnumerateQuest(s *Session, p mhfpacket.MHFPacket) { quests, err := s.server.eventRepo.GetEventQuests() if err == nil { currentTime := time.Now() - tx, err := s.server.eventRepo.BeginTx() - if err != nil { - s.logger.Error("Failed to begin transaction for event quests", zap.Error(err)) - doAckBufSucceed(s, pkt.AckHandle, bf.Data()) - return - } + var updates []EventQuestUpdate for i, eq := range quests { // Use the Event Cycling system @@ -364,11 +359,7 @@ func handleMsgMhfEnumerateQuest(s *Session, p mhfpacket.MHFPacket) { // Normalize rotationTime to 12PM JST to align with the in-game events update notification. newRotationTime := time.Date(rotationTime.Year(), rotationTime.Month(), rotationTime.Day(), 12, 0, 0, 0, TimeAdjusted().Location()) - err = s.server.eventRepo.UpdateEventQuestStartTime(tx, eq.ID, newRotationTime) - if err != nil { - _ = tx.Rollback() - break - } + updates = append(updates, EventQuestUpdate{ID: eq.ID, StartTime: newRotationTime}) quests[i].StartTime = newRotationTime // Set the new start time so the quest can be used/removed immediately. eq = quests[i] } @@ -399,7 +390,9 @@ func handleMsgMhfEnumerateQuest(s *Session, p mhfpacket.MHFPacket) { } } - _ = tx.Commit() + if err := s.server.eventRepo.UpdateEventQuestStartTimes(updates); err != nil { + s.logger.Error("Failed to update event quest start times", zap.Error(err)) + } } tuneValues := []tuneValue{ diff --git a/server/channelserver/repo_event.go b/server/channelserver/repo_event.go index a8929dfd4..f6c7a7c45 100644 --- a/server/channelserver/repo_event.go +++ b/server/channelserver/repo_event.go @@ -1,7 +1,6 @@ package channelserver import ( - "database/sql" "time" "github.com/jmoiron/sqlx" @@ -69,13 +68,26 @@ func (r *EventRepository) GetEventQuests() ([]EventQuest, error) { return result, err } -// UpdateEventQuestStartTime updates the start_time for an event quest within a transaction. -func (r *EventRepository) UpdateEventQuestStartTime(tx *sql.Tx, id uint32, startTime time.Time) error { - _, err := tx.Exec("UPDATE event_quests SET start_time = $1 WHERE id = $2", startTime, id) - return err +// EventQuestUpdate pairs a quest ID with its new start time. +type EventQuestUpdate struct { + ID uint32 + StartTime time.Time } -// BeginTx starts a new database transaction. -func (r *EventRepository) BeginTx() (*sql.Tx, error) { - return r.db.Begin() +// UpdateEventQuestStartTimes batch-updates start times within a single transaction. +func (r *EventRepository) UpdateEventQuestStartTimes(updates []EventQuestUpdate) error { + if len(updates) == 0 { + return nil + } + tx, err := r.db.Begin() + if err != nil { + return err + } + for _, u := range updates { + if _, err := tx.Exec("UPDATE event_quests SET start_time = $1 WHERE id = $2", u.StartTime, u.ID); err != nil { + _ = tx.Rollback() + return err + } + } + return tx.Commit() } diff --git a/server/channelserver/repo_event_test.go b/server/channelserver/repo_event_test.go index f9c5a14fe..36ad33b56 100644 --- a/server/channelserver/repo_event_test.go +++ b/server/channelserver/repo_event_test.go @@ -96,65 +96,51 @@ func TestGetEventQuestsOrderByQuestID(t *testing.T) { } } -func TestBeginTxAndUpdateEventQuestStartTime(t *testing.T) { +func TestUpdateEventQuestStartTimes(t *testing.T) { repo, db := setupEventRepo(t) originalTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - questID := insertEventQuest(t, db, 1, 100, originalTime, 7, 3) + id1 := insertEventQuest(t, db, 1, 100, originalTime, 7, 3) + id2 := insertEventQuest(t, db, 2, 200, originalTime, 5, 2) - newTime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + newTime1 := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + newTime2 := time.Date(2025, 7, 20, 12, 0, 0, 0, time.UTC) - tx, err := repo.BeginTx() + err := repo.UpdateEventQuestStartTimes([]EventQuestUpdate{ + {ID: id1, StartTime: newTime1}, + {ID: id2, StartTime: newTime2}, + }) if err != nil { - t.Fatalf("BeginTx failed: %v", err) + t.Fatalf("UpdateEventQuestStartTimes failed: %v", err) } - if err := repo.UpdateEventQuestStartTime(tx, questID, newTime); err != nil { - _ = tx.Rollback() - t.Fatalf("UpdateEventQuestStartTime failed: %v", err) + // Verify both updates + var got1, got2 time.Time + if err := db.QueryRow("SELECT start_time FROM event_quests WHERE id=$1", id1).Scan(&got1); err != nil { + t.Fatalf("Verification query failed for id1: %v", err) } - - if err := tx.Commit(); err != nil { - t.Fatalf("Commit failed: %v", err) + if !got1.Equal(newTime1) { + t.Errorf("Expected start_time %v for id1, got: %v", newTime1, got1) } - - // Verify the update - var got time.Time - if err := db.QueryRow("SELECT start_time FROM event_quests WHERE id=$1", questID).Scan(&got); err != nil { - t.Fatalf("Verification query failed: %v", err) + if err := db.QueryRow("SELECT start_time FROM event_quests WHERE id=$1", id2).Scan(&got2); err != nil { + t.Fatalf("Verification query failed for id2: %v", err) } - if !got.Equal(newTime) { - t.Errorf("Expected start_time %v, got: %v", newTime, got) + if !got2.Equal(newTime2) { + t.Errorf("Expected start_time %v for id2, got: %v", newTime2, got2) } } -func TestUpdateEventQuestStartTimeRollback(t *testing.T) { - repo, db := setupEventRepo(t) +func TestUpdateEventQuestStartTimesEmpty(t *testing.T) { + repo, _ := setupEventRepo(t) - originalTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - questID := insertEventQuest(t, db, 1, 100, originalTime, 0, 0) - - tx, err := repo.BeginTx() + // Empty slice should be a no-op + err := repo.UpdateEventQuestStartTimes(nil) if err != nil { - t.Fatalf("BeginTx failed: %v", err) + t.Fatalf("UpdateEventQuestStartTimes with nil should not error, got: %v", err) } - newTime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) - if err := repo.UpdateEventQuestStartTime(tx, questID, newTime); err != nil { - t.Fatalf("UpdateEventQuestStartTime failed: %v", err) - } - - // Rollback instead of commit - if err := tx.Rollback(); err != nil { - t.Fatalf("Rollback failed: %v", err) - } - - // Verify original time is preserved - var got time.Time - if err := db.QueryRow("SELECT start_time FROM event_quests WHERE id=$1", questID).Scan(&got); err != nil { - t.Fatalf("Verification query failed: %v", err) - } - if !got.Equal(originalTime) { - t.Errorf("Expected original start_time %v after rollback, got: %v", originalTime, got) + err = repo.UpdateEventQuestStartTimes([]EventQuestUpdate{}) + if err != nil { + t.Fatalf("UpdateEventQuestStartTimes with empty slice should not error, got: %v", err) } } diff --git a/server/channelserver/repo_guild.go b/server/channelserver/repo_guild.go index 4eb0c27a9..cd21f4415 100644 --- a/server/channelserver/repo_guild.go +++ b/server/channelserver/repo_guild.go @@ -270,15 +270,30 @@ func (r *GuildRepository) AcceptApplication(guildID, charID uint32) error { } // CreateApplication inserts a guild application or invitation. -// If tx is non-nil, the operation participates in the given transaction. -func (r *GuildRepository) CreateApplication(guildID, charID, actorID uint32, appType GuildApplicationType, tx *sql.Tx) error { - query := `INSERT INTO guild_applications (guild_id, character_id, actor_id, application_type) VALUES ($1, $2, $3, $4)` - if tx != nil { - _, err := tx.Exec(query, guildID, charID, actorID, appType) +func (r *GuildRepository) CreateApplication(guildID, charID, actorID uint32, appType GuildApplicationType) error { + _, err := r.db.Exec( + `INSERT INTO guild_applications (guild_id, character_id, actor_id, application_type) VALUES ($1, $2, $3, $4)`, + guildID, charID, actorID, appType) + 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 { + tx, err := r.db.Begin() + if err != nil { return err } - _, err := r.db.Exec(query, guildID, charID, actorID, appType) - return err + 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 { + _ = tx.Rollback() + return err + } + if _, err := tx.Exec(mailInsertQuery, mailSenderID, mailRecipientID, mailSubject, mailBody, 0, 0, true, false); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() } // CancelInvitation removes an invitation for a character. diff --git a/server/channelserver/repo_guild_test.go b/server/channelserver/repo_guild_test.go index 84449de52..fc2ff6c3c 100644 --- a/server/channelserver/repo_guild_test.go +++ b/server/channelserver/repo_guild_test.go @@ -270,7 +270,7 @@ func TestApplicationWorkflow(t *testing.T) { applicantID := CreateTestCharacter(t, db, user2, "Applicant") // Create application - err := repo.CreateApplication(guildID, applicantID, applicantID, GuildApplicationTypeApplied, nil) + err := repo.CreateApplication(guildID, applicantID, applicantID, GuildApplicationTypeApplied) if err != nil { t.Fatalf("CreateApplication failed: %v", err) } @@ -324,7 +324,7 @@ func TestRejectApplication(t *testing.T) { user2 := CreateTestUser(t, db, "reject_user") applicantID := CreateTestCharacter(t, db, user2, "Rejected") - err := repo.CreateApplication(guildID, applicantID, applicantID, GuildApplicationTypeApplied, nil) + err := repo.CreateApplication(guildID, applicantID, applicantID, GuildApplicationTypeApplied) if err != nil { t.Fatalf("CreateApplication failed: %v", err) } @@ -539,7 +539,7 @@ func TestCancelInvitation(t *testing.T) { user2 := CreateTestUser(t, db, "invite_user") char2 := CreateTestCharacter(t, db, user2, "Invited") - if err := repo.CreateApplication(guildID, char2, leaderID, GuildApplicationTypeInvited, nil); err != nil { + if err := repo.CreateApplication(guildID, char2, leaderID, GuildApplicationTypeInvited); err != nil { t.Fatalf("CreateApplication (invited) failed: %v", err) } @@ -562,7 +562,7 @@ func TestListInvitedCharacters(t *testing.T) { user2 := CreateTestUser(t, db, "scout_user") char2 := CreateTestCharacter(t, db, user2, "Scouted") - if err := repo.CreateApplication(guildID, char2, leaderID, GuildApplicationTypeInvited, nil); err != nil { + if err := repo.CreateApplication(guildID, char2, leaderID, GuildApplicationTypeInvited); err != nil { t.Fatalf("CreateApplication failed: %v", err) } @@ -602,7 +602,7 @@ func TestGetByCharIDWithApplication(t *testing.T) { user2 := CreateTestUser(t, db, "app_char_user") char2 := CreateTestCharacter(t, db, user2, "Applicant2") - if err := repo.CreateApplication(guildID, char2, char2, GuildApplicationTypeApplied, nil); err != nil { + if err := repo.CreateApplication(guildID, char2, char2, GuildApplicationTypeApplied); err != nil { t.Fatalf("CreateApplication failed: %v", err) } @@ -624,7 +624,7 @@ func TestGetMembersApplicants(t *testing.T) { user2 := CreateTestUser(t, db, "applicant_member_user") char2 := CreateTestCharacter(t, db, user2, "AppMember") - if err := repo.CreateApplication(guildID, char2, char2, GuildApplicationTypeApplied, nil); err != nil { + if err := repo.CreateApplication(guildID, char2, char2, GuildApplicationTypeApplied); err != nil { t.Fatalf("CreateApplication failed: %v", err) } @@ -1485,3 +1485,39 @@ func TestDisbandCleansUpAlliance(t *testing.T) { t.Errorf("Expected alliance to be deleted after parent guild disband, got: %+v", alliance) } } + +// --- CreateApplicationWithMail --- + +func TestCreateApplicationWithMail(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!") + if err != nil { + t.Fatalf("CreateApplicationWithMail failed: %v", err) + } + + // Verify application was created + has, err := repo.HasApplication(guildID, char2) + if err != nil { + t.Fatalf("HasApplication failed: %v", err) + } + if !has { + t.Error("Expected application to exist after CreateApplicationWithMail") + } + + // Verify mail was sent + var mailCount int + if err := db.QueryRow( + "SELECT COUNT(*) FROM mail WHERE sender_id=$1 AND recipient_id=$2 AND subject=$3", + leaderID, char2, "Guild Invite").Scan(&mailCount); err != nil { + t.Fatalf("Mail verification query failed: %v", err) + } + if mailCount != 1 { + t.Errorf("Expected 1 mail row, got %d", mailCount) + } +} diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index c0cc52a3c..4dfdee0dc 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -1,7 +1,6 @@ package channelserver import ( - "database/sql" "time" ) @@ -52,7 +51,8 @@ type GuildRepo interface { Disband(guildID uint32) error RemoveCharacter(charID uint32) error AcceptApplication(guildID, charID uint32) error - CreateApplication(guildID, charID, actorID uint32, appType GuildApplicationType, tx *sql.Tx) 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 RejectApplication(guildID, charID uint32) error ArrangeCharacters(charIDs []uint32) error @@ -228,7 +228,6 @@ type RengokuRepo interface { // MailRepo defines the contract for in-game mail data access. type MailRepo interface { SendMail(senderID, recipientID uint32, subject, body string, itemID, itemAmount uint16, isGuildInvite, isSystemMessage bool) error - SendMailTx(tx *sql.Tx, senderID, recipientID uint32, subject, body string, itemID, itemAmount uint16, isGuildInvite, isSystemMessage bool) error GetListForCharacter(charID uint32) ([]Mail, error) GetByID(id int) (*Mail, error) MarkRead(id int) error @@ -272,8 +271,7 @@ type EventRepo interface { InsertLoginBoost(charID uint32, weekReq uint8, expiration, reset time.Time) error UpdateLoginBoost(charID uint32, weekReq uint8, expiration, reset time.Time) error GetEventQuests() ([]EventQuest, error) - UpdateEventQuestStartTime(tx *sql.Tx, id uint32, startTime time.Time) error - BeginTx() (*sql.Tx, error) + UpdateEventQuestStartTimes(updates []EventQuestUpdate) error } // AchievementRepo defines the contract for achievement data access. diff --git a/server/channelserver/repo_mail.go b/server/channelserver/repo_mail.go index 24da7543a..b9023f6da 100644 --- a/server/channelserver/repo_mail.go +++ b/server/channelserver/repo_mail.go @@ -1,8 +1,6 @@ package channelserver import ( - "database/sql" - "github.com/jmoiron/sqlx" ) @@ -27,12 +25,6 @@ func (r *MailRepository) SendMail(senderID, recipientID uint32, subject, body st return err } -// SendMailTx inserts a new mail row within an existing transaction. -func (r *MailRepository) SendMailTx(tx *sql.Tx, senderID, recipientID uint32, subject, body string, itemID, itemAmount uint16, isGuildInvite, isSystemMessage bool) error { - _, err := tx.Exec(mailInsertQuery, senderID, recipientID, subject, body, itemID, itemAmount, isGuildInvite, isSystemMessage) - return err -} - // GetListForCharacter loads all non-deleted mail for a character (max 32). func (r *MailRepository) GetListForCharacter(charID uint32) ([]Mail, error) { rows, err := r.db.Queryx(` diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index e48597b2b..4eab51696 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -1,7 +1,6 @@ package channelserver import ( - "database/sql" "errors" "time" ) @@ -102,10 +101,6 @@ func (m *mockMailRepo) SendMail(senderID, recipientID uint32, subject, body stri return m.sendErr } -func (m *mockMailRepo) SendMailTx(_ *sql.Tx, senderID, recipientID uint32, subject, body string, itemID, itemAmount uint16, isGuildInvite, isSystemMessage bool) error { - return m.SendMail(senderID, recipientID, subject, body, itemID, itemAmount, isGuildInvite, isSystemMessage) -} - // --- mockCharacterRepo --- type mockCharacterRepo struct { @@ -271,7 +266,10 @@ func (m *mockGuildRepoForMail) Save(_ *Guild) error { return func (m *mockGuildRepoForMail) Disband(_ uint32) error { return nil } func (m *mockGuildRepoForMail) RemoveCharacter(_ uint32) error { return nil } func (m *mockGuildRepoForMail) AcceptApplication(_, _ uint32) error { return nil } -func (m *mockGuildRepoForMail) CreateApplication(_, _, _ uint32, _ GuildApplicationType, _ *sql.Tx) error { +func (m *mockGuildRepoForMail) CreateApplication(_, _, _ uint32, _ GuildApplicationType) error { + return nil +} +func (m *mockGuildRepoForMail) CreateApplicationWithMail(_, _, _ uint32, _ GuildApplicationType, _, _ uint32, _, _ string) error { return nil } func (m *mockGuildRepoForMail) CancelInvitation(_, _ uint32) error { return nil }