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

@@ -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
}