package repository import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/yuxingu/digital-psychology/apps/api/internal/model" ) // SynastryInviteRepo persists synastry invite tokens. type SynastryInviteRepo struct { Pool *pgxpool.Pool } // Create inserts an invite (7-day expiry). func (r *SynastryInviteRepo) Create(ctx context.Context, hostUser, hostProfile uuid.UUID) (*model.SynastryInvite, error) { token, err := randomToken(16) if err != nil { return nil, err } exp := time.Now().UTC().Add(7 * 24 * time.Hour) inv := &model.SynastryInvite{} err = r.Pool.QueryRow(ctx, ` INSERT INTO synastry_invites(token, host_user_id, host_profile_id, expires_at) VALUES ($1,$2,$3,$4) RETURNING id, token, host_user_id, host_profile_id, expires_at, guest_user_id, guest_profile_id, report_id, created_at`, token, hostUser, hostProfile, exp, ).Scan(&inv.ID, &inv.Token, &inv.HostUserID, &inv.HostProfileID, &inv.ExpiresAt, &inv.GuestUserID, &inv.GuestProfileID, &inv.ReportID, &inv.CreatedAt) return inv, err } // GetByToken loads a non-deleted invite. func (r *SynastryInviteRepo) GetByToken(ctx context.Context, token string) (*model.SynastryInvite, error) { inv := &model.SynastryInvite{} err := r.Pool.QueryRow(ctx, ` SELECT id, token, host_user_id, host_profile_id, expires_at, guest_user_id, guest_profile_id, report_id, created_at FROM synastry_invites WHERE token=$1 AND deleted_at IS NULL`, token, ).Scan(&inv.ID, &inv.Token, &inv.HostUserID, &inv.HostProfileID, &inv.ExpiresAt, &inv.GuestUserID, &inv.GuestProfileID, &inv.ReportID, &inv.CreatedAt) if err != nil { return nil, err } return inv, nil } // AcceptAtomic creates guest profile + synastry report + marks invite in one transaction. func (r *SynastryInviteRepo) AcceptAtomic( ctx context.Context, inviteID, guestUser uuid.UUID, name string, birth time.Time, birthTime, birthPlace *string, summary, detail json.RawMessage, ) (*model.GrowthReport, error) { tx, err := r.Pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var guestID uuid.UUID var bt *string var bp *string var createdAt time.Time err = tx.QueryRow(ctx, ` INSERT INTO profiles(user_id, relation, display_name, birth_date, birth_time, birth_place) VALUES ($1,'other',$2,$3, CASE WHEN $4::text IS NULL OR $4::text = '' THEN NULL ELSE $4::time END, NULLIF(TRIM($5::text), '')) RETURNING id, CASE WHEN birth_time IS NULL THEN NULL ELSE to_char(birth_time, 'HH24:MI') END, birth_place, created_at`, guestUser, name, birth, birthTime, birthPlace, ).Scan(&guestID, &bt, &bp, &createdAt) if err != nil { return nil, err } rep := &model.GrowthReport{} err = tx.QueryRow(ctx, ` INSERT INTO growth_reports(user_id, profile_id, type, summary, detail) VALUES ($1,$2,'synastry',$3,$4) RETURNING id, user_id, profile_id, type, summary, detail, created_at`, guestUser, guestID, summary, detail, ).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt) if err != nil { return nil, err } tag, err := tx.Exec(ctx, ` UPDATE synastry_invites SET guest_user_id=$2, guest_profile_id=$3, report_id=$4, updated_at=now() WHERE id=$1 AND deleted_at IS NULL AND report_id IS NULL AND expires_at > now()`, inviteID, guestUser, guestID, rep.ID, ) if err != nil { return nil, err } if tag.RowsAffected() == 0 { return nil, errors.New("invite already used or expired") } if err := tx.Commit(ctx); err != nil { return nil, err } return rep, nil } func randomToken(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil }