chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user