446 lines
21 KiB
Go
446 lines
21 KiB
Go
package saas
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/hex"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"data-terminal-cloud/internal/audit"
|
||
|
|
"data-terminal-cloud/internal/database"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
ErrInvitationInvalid = errors.New("member invitation is invalid")
|
||
|
|
ErrMemberExists = errors.New("active household member already exists")
|
||
|
|
ErrOwnerProtected = errors.New("household owner must be transferred first")
|
||
|
|
)
|
||
|
|
|
||
|
|
type HouseholdMember struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
UserID string `json:"user_id"`
|
||
|
|
DisplayName string `json:"display_name"`
|
||
|
|
Role identity.Role `json:"role"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
JoinedAt time.Time `json:"joined_at"`
|
||
|
|
RemovedAt *time.Time `json:"removed_at,omitempty"`
|
||
|
|
Version int64 `json:"version"`
|
||
|
|
IsOwner bool `json:"is_owner"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type MemberInvitation struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
HouseholdID string `json:"household_id"`
|
||
|
|
InviteeHint string `json:"invitee_hint,omitempty"`
|
||
|
|
Role identity.Role `json:"role"`
|
||
|
|
ExpiresAt time.Time `json:"expires_at"`
|
||
|
|
UsedAt *time.Time `json:"used_at,omitempty"`
|
||
|
|
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
Token string `json:"token,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type MemberInvitationPreview struct {
|
||
|
|
TenantID string `json:"tenant_id"`
|
||
|
|
TenantName string `json:"tenant_name"`
|
||
|
|
HouseholdID string `json:"household_id"`
|
||
|
|
HouseholdName string `json:"household_name"`
|
||
|
|
InviteeHint string `json:"invitee_hint,omitempty"`
|
||
|
|
Role identity.Role `json:"role"`
|
||
|
|
ExpiresAt time.Time `json:"expires_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) PreviewMemberInvitation(ctx context.Context, token string) (MemberInvitationPreview, error) {
|
||
|
|
digest := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
||
|
|
var result MemberInvitationPreview
|
||
|
|
var usedAt, revokedAt *time.Time
|
||
|
|
err := s.pool.QueryRow(ctx, `
|
||
|
|
SELECT t.public_id::text,t.name,h.public_id::text,h.name,COALESCE(mi.invitee_hint,''),mi.role,mi.expires_at,mi.used_at,mi.revoked_at
|
||
|
|
FROM member_invitation mi
|
||
|
|
JOIN household h ON h.id=mi.household_id AND h.tenant_id=mi.tenant_id AND h.status='active'
|
||
|
|
JOIN tenant t ON t.id=mi.tenant_id AND t.status='active'
|
||
|
|
WHERE mi.token_digest=$1`, hex.EncodeToString(digest[:])).Scan(&result.TenantID, &result.TenantName, &result.HouseholdID, &result.HouseholdName, &result.InviteeHint, &result.Role, &result.ExpiresAt, &usedAt, &revokedAt)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return MemberInvitationPreview{}, ErrInvitationInvalid
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return MemberInvitationPreview{}, fmt.Errorf("preview member invitation: %w", err)
|
||
|
|
}
|
||
|
|
if usedAt != nil || revokedAt != nil || !result.ExpiresAt.After(time.Now()) {
|
||
|
|
return MemberInvitationPreview{}, ErrInvitationInvalid
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) ListHouseholdMembers(ctx context.Context, principal identity.Principal, householdPublicID string) ([]HouseholdMember, error) {
|
||
|
|
access, err := resolveHouseholdAccess(ctx, s.pool, principal, householdPublicID, identity.CapabilityHouseholdRead, false)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
rows, err := s.pool.Query(ctx, `
|
||
|
|
SELECT hm.public_id::text, ua.public_id::text, ua.display_name, hm.role, hm.status,
|
||
|
|
hm.joined_at, hm.removed_at, hm.version, hm.user_id=h.owner_user_id
|
||
|
|
FROM household_member hm
|
||
|
|
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=hm.tenant_id
|
||
|
|
JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id
|
||
|
|
WHERE hm.tenant_id=$1::uuid AND hm.household_id=$2::uuid AND hm.status='active'
|
||
|
|
ORDER BY (hm.user_id=h.owner_user_id) DESC, hm.joined_at, hm.public_id`, access.tenantID, access.householdID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("list household members: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := make([]HouseholdMember, 0)
|
||
|
|
for rows.Next() {
|
||
|
|
item, err := scanHouseholdMember(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("scan household member: %w", err)
|
||
|
|
}
|
||
|
|
items = append(items, item)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, fmt.Errorf("iterate household members: %w", err)
|
||
|
|
}
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) ListMemberInvitations(ctx context.Context, principal identity.Principal, householdPublicID string) ([]MemberInvitation, error) {
|
||
|
|
access, err := resolveHouseholdAccess(ctx, s.pool, principal, householdPublicID, identity.CapabilityMemberManage, false)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
rows, err := s.pool.Query(ctx, `
|
||
|
|
SELECT public_id::text,$1::text,COALESCE(invitee_hint,''),role,expires_at,used_at,revoked_at,created_at
|
||
|
|
FROM member_invitation WHERE tenant_id=$2::uuid AND household_id=$3::uuid
|
||
|
|
ORDER BY created_at DESC, public_id`, householdPublicID, access.tenantID, access.householdID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("list member invitations: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := make([]MemberInvitation, 0)
|
||
|
|
for rows.Next() {
|
||
|
|
item, err := scanInvitation(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("scan member invitation: %w", err)
|
||
|
|
}
|
||
|
|
items = append(items, item)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, fmt.Errorf("iterate member invitations: %w", err)
|
||
|
|
}
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) CreateMemberInvitation(ctx context.Context, principal identity.Principal, householdPublicID, inviteeHint string, role identity.Role, expiresAt time.Time, requestID string) (MemberInvitation, error) {
|
||
|
|
tokenBytes := make([]byte, 32)
|
||
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
||
|
|
return MemberInvitation{}, fmt.Errorf("generate invitation token: %w", err)
|
||
|
|
}
|
||
|
|
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
|
||
|
|
digest := sha256.Sum256([]byte(token))
|
||
|
|
var result MemberInvitation
|
||
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||
|
|
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityMemberManage, true)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
row := tx.QueryRow(ctx, `
|
||
|
|
INSERT INTO member_invitation (tenant_id,household_id,invitee_hint,token_digest,role,expires_at,created_by)
|
||
|
|
VALUES ($1::uuid,$2::uuid,NULLIF($3,''),$4,$5,$6,$7::uuid)
|
||
|
|
RETURNING public_id::text,$8::text,COALESCE(invitee_hint,''),role,expires_at,used_at,revoked_at,created_at`,
|
||
|
|
access.tenantID, access.householdID, inviteeHint, hex.EncodeToString(digest[:]), role, expiresAt, access.userID, householdPublicID)
|
||
|
|
result, err = scanInvitation(row)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("insert member invitation: %w", err)
|
||
|
|
}
|
||
|
|
result.Token = token
|
||
|
|
return s.audit.Record(ctx, tx, audit.Event{
|
||
|
|
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
|
||
|
|
Action: "member.invitation.create", ResourceType: "member_invitation", ResourceID: strPtr(result.ID),
|
||
|
|
RequestID: requestID, Result: "success", Detail: map[string]any{"household_id": householdPublicID, "role": role, "expires_at": expiresAt},
|
||
|
|
})
|
||
|
|
})
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) RevokeMemberInvitation(ctx context.Context, principal identity.Principal, householdPublicID, invitationPublicID, requestID string) (MemberInvitation, error) {
|
||
|
|
var result MemberInvitation
|
||
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||
|
|
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityMemberManage, true)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
row := tx.QueryRow(ctx, `
|
||
|
|
UPDATE member_invitation SET revoked_at=COALESCE(revoked_at,now())
|
||
|
|
WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND household_id=$3::uuid AND used_at IS NULL
|
||
|
|
RETURNING public_id::text,$4::text,COALESCE(invitee_hint,''),role,expires_at,used_at,revoked_at,created_at`,
|
||
|
|
invitationPublicID, access.tenantID, access.householdID, householdPublicID)
|
||
|
|
result, err = scanInvitation(row)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return ErrNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("revoke member invitation: %w", err)
|
||
|
|
}
|
||
|
|
return s.audit.Record(ctx, tx, audit.Event{
|
||
|
|
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
|
||
|
|
Action: "member.invitation.revoke", ResourceType: "member_invitation", ResourceID: strPtr(result.ID),
|
||
|
|
RequestID: requestID, Result: "success", Detail: map[string]any{"household_id": householdPublicID},
|
||
|
|
})
|
||
|
|
})
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) AcceptMemberInvitation(ctx context.Context, principal identity.Principal, token, requestID string) (HouseholdMember, bool, error) {
|
||
|
|
digest := sha256.Sum256([]byte(token))
|
||
|
|
var result HouseholdMember
|
||
|
|
var replayed bool
|
||
|
|
var failureTenantID, failureUserPublicID, failureInvitationPublicID, failureHouseholdPublicID string
|
||
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||
|
|
var invitationID, invitationPublicID, tenantID, householdID, householdPublicID, role string
|
||
|
|
var expiresAt time.Time
|
||
|
|
var usedAt, revokedAt *time.Time
|
||
|
|
err := tx.QueryRow(ctx, `
|
||
|
|
SELECT mi.id::text,mi.public_id::text,mi.tenant_id::text,mi.household_id::text,h.public_id::text,mi.role,mi.expires_at,mi.used_at,mi.revoked_at
|
||
|
|
FROM member_invitation mi JOIN household h ON h.id=mi.household_id AND h.tenant_id=mi.tenant_id AND h.status='active'
|
||
|
|
WHERE mi.token_digest=$1 FOR UPDATE OF mi`, hex.EncodeToString(digest[:])).Scan(
|
||
|
|
&invitationID, &invitationPublicID, &tenantID, &householdID, &householdPublicID, &role, &expiresAt, &usedAt, &revokedAt)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return ErrInvitationInvalid
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("resolve member invitation: %w", err)
|
||
|
|
}
|
||
|
|
var userID, userPublicID string
|
||
|
|
if err := tx.QueryRow(ctx, `
|
||
|
|
SELECT id::text,public_id::text FROM user_account
|
||
|
|
WHERE tenant_id=$1::uuid AND oidc_issuer=$2 AND oidc_subject=$3 AND status='active'`,
|
||
|
|
tenantID, principal.Issuer, principal.Subject).Scan(&userID, &userPublicID); errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
displayName := strings.TrimSpace(principal.DisplayName)
|
||
|
|
if displayName == "" {
|
||
|
|
displayName = strings.TrimSpace(principal.PreferredUsername)
|
||
|
|
}
|
||
|
|
if displayName == "" {
|
||
|
|
displayName = "受邀家庭成员"
|
||
|
|
}
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
INSERT INTO user_account (tenant_id,oidc_issuer,oidc_subject,display_name,status,last_login_at)
|
||
|
|
VALUES ($1::uuid,$2,$3,$4,'active',now())
|
||
|
|
ON CONFLICT (tenant_id,oidc_issuer,oidc_subject) DO NOTHING
|
||
|
|
RETURNING id::text,public_id::text`, tenantID, principal.Issuer, principal.Subject, displayName).Scan(&userID, &userPublicID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return ErrAccessDenied
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("create invited tenant account: %w", err)
|
||
|
|
}
|
||
|
|
} else if err != nil {
|
||
|
|
return fmt.Errorf("resolve invitation account: %w", err)
|
||
|
|
}
|
||
|
|
failureTenantID, failureUserPublicID = tenantID, userPublicID
|
||
|
|
failureInvitationPublicID, failureHouseholdPublicID = invitationPublicID, householdPublicID
|
||
|
|
if usedAt != nil {
|
||
|
|
return ErrInvitationInvalid
|
||
|
|
}
|
||
|
|
if revokedAt != nil || !expiresAt.After(time.Now()) {
|
||
|
|
return ErrInvitationInvalid
|
||
|
|
}
|
||
|
|
var active bool
|
||
|
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM household_member WHERE household_id=$1::uuid AND user_id=$2::uuid AND status='active')`, householdID, userID).Scan(&active); err != nil {
|
||
|
|
return fmt.Errorf("check existing household member: %w", err)
|
||
|
|
}
|
||
|
|
if active {
|
||
|
|
return ErrMemberExists
|
||
|
|
}
|
||
|
|
row := tx.QueryRow(ctx, `
|
||
|
|
INSERT INTO household_member (tenant_id,household_id,user_id,role,status)
|
||
|
|
VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'active')
|
||
|
|
RETURNING public_id::text,$5::text,(SELECT display_name FROM user_account WHERE id=$3::uuid),role,status,joined_at,removed_at,version,false`,
|
||
|
|
tenantID, householdID, userID, role, userPublicID)
|
||
|
|
result, err = scanHouseholdMember(row)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("insert invited household member: %w", err)
|
||
|
|
}
|
||
|
|
if _, err := tx.Exec(ctx, `UPDATE member_invitation SET used_at=now() WHERE id=$1::uuid`, invitationID); err != nil {
|
||
|
|
return fmt.Errorf("consume member invitation: %w", err)
|
||
|
|
}
|
||
|
|
return s.audit.Record(ctx, tx, audit.Event{
|
||
|
|
TenantID: strPtr(tenantID), ActorType: "user", ActorPublicID: strPtr(userPublicID),
|
||
|
|
Action: "member.invitation.accept", ResourceType: "household_member", ResourceID: strPtr(result.ID),
|
||
|
|
RequestID: requestID, Result: "success", Detail: map[string]any{"household_id": householdPublicID, "role": role},
|
||
|
|
})
|
||
|
|
})
|
||
|
|
if errors.Is(err, ErrInvitationInvalid) && failureTenantID != "" {
|
||
|
|
auditErr := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||
|
|
return s.audit.Record(ctx, tx, audit.Event{
|
||
|
|
TenantID: strPtr(failureTenantID), ActorType: "user", ActorPublicID: strPtr(failureUserPublicID),
|
||
|
|
Action: "member.invitation.accept", ResourceType: "member_invitation", ResourceID: strPtr(failureInvitationPublicID),
|
||
|
|
RequestID: requestID, Result: "failure", Detail: map[string]any{"household_id": failureHouseholdPublicID, "reason": "invalid_expired_revoked_or_used"},
|
||
|
|
})
|
||
|
|
})
|
||
|
|
if auditErr != nil {
|
||
|
|
return HouseholdMember{}, false, fmt.Errorf("record failed invitation acceptance: %w", auditErr)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result, replayed, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) PatchHouseholdMemberRole(ctx context.Context, principal identity.Principal, householdPublicID, memberPublicID string, role identity.Role, expectedVersion int64, requestID string) (HouseholdMember, error) {
|
||
|
|
var result HouseholdMember
|
||
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||
|
|
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityMemberManage, true)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var targetID, targetUserID, currentRole string
|
||
|
|
var version int64
|
||
|
|
if err := tx.QueryRow(ctx, `SELECT id::text,user_id::text,role,version FROM household_member WHERE public_id=$1::uuid AND household_id=$2::uuid AND tenant_id=$3::uuid AND status='active' FOR UPDATE`, memberPublicID, access.householdID, access.tenantID).Scan(&targetID, &targetUserID, ¤tRole, &version); errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return ErrNotFound
|
||
|
|
} else if err != nil {
|
||
|
|
return fmt.Errorf("resolve household member: %w", err)
|
||
|
|
}
|
||
|
|
if currentRole == string(identity.RoleOwner) {
|
||
|
|
return ErrOwnerProtected
|
||
|
|
}
|
||
|
|
if version != expectedVersion {
|
||
|
|
return ErrVersionConflict
|
||
|
|
}
|
||
|
|
row := tx.QueryRow(ctx, `
|
||
|
|
UPDATE household_member hm SET role=$1,version=hm.version+1,updated_at=now()
|
||
|
|
FROM user_account ua, household h
|
||
|
|
WHERE hm.id=$2::uuid AND ua.id=hm.user_id AND h.id=hm.household_id
|
||
|
|
RETURNING hm.public_id::text,ua.public_id::text,ua.display_name,hm.role,hm.status,hm.joined_at,hm.removed_at,hm.version,hm.user_id=h.owner_user_id`, role, targetID)
|
||
|
|
result, err = scanHouseholdMember(row)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("update household member role: %w", err)
|
||
|
|
}
|
||
|
|
return s.audit.Record(ctx, tx, audit.Event{
|
||
|
|
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
|
||
|
|
Action: "household_member.role.update", ResourceType: "household_member", ResourceID: strPtr(result.ID), RequestID: requestID,
|
||
|
|
Result: "success", Detail: map[string]any{"household_id": householdPublicID, "from_role": currentRole, "to_role": role},
|
||
|
|
})
|
||
|
|
})
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) RemoveHouseholdMember(ctx context.Context, principal identity.Principal, householdPublicID, memberPublicID string, expectedVersion int64, requestID string) (HouseholdMember, error) {
|
||
|
|
var result HouseholdMember
|
||
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||
|
|
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityMemberManage, true)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
row := tx.QueryRow(ctx, `
|
||
|
|
SELECT hm.public_id::text,ua.public_id::text,ua.display_name,hm.role,hm.status,hm.joined_at,hm.removed_at,hm.version,hm.user_id=h.owner_user_id
|
||
|
|
FROM household_member hm JOIN user_account ua ON ua.id=hm.user_id JOIN household h ON h.id=hm.household_id
|
||
|
|
WHERE hm.public_id=$1::uuid AND hm.household_id=$2::uuid AND hm.tenant_id=$3::uuid AND hm.status='active' FOR UPDATE OF hm`, memberPublicID, access.householdID, access.tenantID)
|
||
|
|
current, err := scanHouseholdMember(row)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return ErrNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("resolve household member removal: %w", err)
|
||
|
|
}
|
||
|
|
if current.IsOwner {
|
||
|
|
return ErrOwnerProtected
|
||
|
|
}
|
||
|
|
if current.Version != expectedVersion {
|
||
|
|
return ErrVersionConflict
|
||
|
|
}
|
||
|
|
row = tx.QueryRow(ctx, `
|
||
|
|
UPDATE household_member hm SET status='removed',removed_at=now(),version=hm.version+1,updated_at=now()
|
||
|
|
FROM user_account ua WHERE hm.public_id=$1::uuid AND ua.id=hm.user_id
|
||
|
|
RETURNING hm.public_id::text,ua.public_id::text,ua.display_name,hm.role,hm.status,hm.joined_at,hm.removed_at,hm.version,false`, memberPublicID)
|
||
|
|
result, err = scanHouseholdMember(row)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("remove household member: %w", err)
|
||
|
|
}
|
||
|
|
return s.audit.Record(ctx, tx, audit.Event{
|
||
|
|
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
|
||
|
|
Action: "household_member.remove", ResourceType: "household_member", ResourceID: strPtr(result.ID), RequestID: requestID,
|
||
|
|
Result: "success", Detail: map[string]any{"household_id": householdPublicID, "role": current.Role},
|
||
|
|
})
|
||
|
|
})
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) TransferHouseholdOwnership(ctx context.Context, principal identity.Principal, householdPublicID, targetMemberPublicID string, expectedVersion int64, requestID string) (Household, error) {
|
||
|
|
var result Household
|
||
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||
|
|
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityOwnershipTransfer, true)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if access.version != expectedVersion {
|
||
|
|
return ErrVersionConflict
|
||
|
|
}
|
||
|
|
var targetUserID, targetRole string
|
||
|
|
if err := tx.QueryRow(ctx, `SELECT user_id::text,role FROM household_member WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND household_id=$3::uuid AND status='active' FOR UPDATE`, targetMemberPublicID, access.tenantID, access.householdID).Scan(&targetUserID, &targetRole); errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return ErrNotFound
|
||
|
|
} else if err != nil {
|
||
|
|
return fmt.Errorf("resolve ownership target: %w", err)
|
||
|
|
}
|
||
|
|
if targetUserID == access.userID {
|
||
|
|
return ErrMemberExists
|
||
|
|
}
|
||
|
|
if _, err := tx.Exec(ctx, `UPDATE household_member SET role='admin',version=version+1,updated_at=now() WHERE household_id=$1::uuid AND user_id=$2::uuid AND status='active'`, access.householdID, access.userID); err != nil {
|
||
|
|
return fmt.Errorf("demote former household owner: %w", err)
|
||
|
|
}
|
||
|
|
if _, err := tx.Exec(ctx, `UPDATE household_member SET role='owner',version=version+1,updated_at=now() WHERE household_id=$1::uuid AND user_id=$2::uuid AND status='active'`, access.householdID, targetUserID); err != nil {
|
||
|
|
return fmt.Errorf("promote household owner: %w", err)
|
||
|
|
}
|
||
|
|
if err := tx.QueryRow(ctx, `
|
||
|
|
UPDATE household SET owner_user_id=$1::uuid,version=version+1,updated_at=now() WHERE id=$2::uuid
|
||
|
|
RETURNING public_id::text,$3::text,name,status,version,created_at,updated_at`, targetUserID, access.householdID, access.tenantPublicID).Scan(
|
||
|
|
&result.ID, &result.TenantID, &result.Name, &result.Status, &result.Version, &result.CreatedAt, &result.UpdatedAt); err != nil {
|
||
|
|
return fmt.Errorf("transfer household ownership: %w", err)
|
||
|
|
}
|
||
|
|
result.Role = identity.RoleAdmin
|
||
|
|
result.Capabilities = identity.CapabilitiesForRole(result.Role)
|
||
|
|
return s.audit.Record(ctx, tx, audit.Event{
|
||
|
|
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
|
||
|
|
Action: "household.ownership.transfer", ResourceType: "household", ResourceID: strPtr(result.ID), RequestID: requestID,
|
||
|
|
Result: "success", Detail: map[string]any{"target_member_id": targetMemberPublicID, "target_previous_role": targetRole, "version": result.Version},
|
||
|
|
})
|
||
|
|
})
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func scanHouseholdMember(row rowScanner) (HouseholdMember, error) {
|
||
|
|
var item HouseholdMember
|
||
|
|
var role string
|
||
|
|
if err := row.Scan(&item.ID, &item.UserID, &item.DisplayName, &role, &item.Status, &item.JoinedAt, &item.RemovedAt, &item.Version, &item.IsOwner); err != nil {
|
||
|
|
return HouseholdMember{}, err
|
||
|
|
}
|
||
|
|
item.Role = identity.Role(role)
|
||
|
|
return item, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func scanInvitation(row rowScanner) (MemberInvitation, error) {
|
||
|
|
var item MemberInvitation
|
||
|
|
var role string
|
||
|
|
if err := row.Scan(&item.ID, &item.HouseholdID, &item.InviteeHint, &role, &item.ExpiresAt, &item.UsedAt, &item.RevokedAt, &item.CreatedAt); err != nil {
|
||
|
|
return MemberInvitation{}, err
|
||
|
|
}
|
||
|
|
item.Role = identity.Role(role)
|
||
|
|
switch {
|
||
|
|
case item.UsedAt != nil:
|
||
|
|
item.Status = "used"
|
||
|
|
case item.RevokedAt != nil:
|
||
|
|
item.Status = "revoked"
|
||
|
|
case !item.ExpiresAt.After(time.Now()):
|
||
|
|
item.Status = "expired"
|
||
|
|
default:
|
||
|
|
item.Status = "pending"
|
||
|
|
}
|
||
|
|
return item, nil
|
||
|
|
}
|