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 }