ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
447 lines
18 KiB
Go
447 lines
18 KiB
Go
package onboarding
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"time"
|
|
|
|
"data-terminal-cloud/internal/identity"
|
|
"data-terminal-cloud/internal/saas"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type WebSession struct {
|
|
Hash, SetupID, UserID, Issuer, Subject string
|
|
Device Device
|
|
Expires time.Time
|
|
}
|
|
type BrowserCredentials struct {
|
|
TrustedDeviceToken string `json:"-"`
|
|
Token string `json:"-"`
|
|
CSRF string `json:"csrf_token"`
|
|
AccountToken string `json:"-"`
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
|
|
func (s *Service) webSession(ctx context.Context, q queryer, hash string) (WebSession, error) {
|
|
var w WebSession
|
|
var id string
|
|
err := q.QueryRow(ctx, `SELECT ws.token_hash,ws.setup_id::text,COALESCE(ws.user_id::text,''),COALESCE(ws.issuer,''),COALESCE(ws.subject,''),ws.expires_at,ss.device_id::text FROM onboarding_web_session ws JOIN device_setup_session ss ON ss.id=ws.setup_id WHERE ws.token_hash=$1 AND ws.revoked_at IS NULL AND ws.expires_at>now()`, hash).Scan(&w.Hash, &w.SetupID, &w.UserID, &w.Issuer, &w.Subject, &w.Expires, &id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return w, fail(401, "WEB_SESSION_EXPIRED", "请从设备重新打开设置入口")
|
|
}
|
|
if err != nil {
|
|
return w, err
|
|
}
|
|
w.Device, err = readDevice(ctx, q, id, false)
|
|
return w, err
|
|
}
|
|
func (s *Service) checkWebActor(ctx context.Context, tx pgx.Tx, hash string, d Device) error {
|
|
w, err := s.webSession(ctx, tx, hash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if w.Device.ID != d.ID {
|
|
return errAccess
|
|
}
|
|
setup, err := readSetup(ctx, tx, w.SetupID, d, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// A closed setup can report a completed binding, but cannot authorize writes.
|
|
if setup.Closed != nil || !setup.Expires.After(s.now()) {
|
|
return fail(410, "SETUP_EXPIRED", "设置入口已关闭,请从设备列表查看结果")
|
|
}
|
|
if w.UserID != "" {
|
|
var active bool
|
|
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM user_account WHERE id=$1::uuid AND tenant_id=$2::uuid AND status='active')`, w.UserID, d.TenantID).Scan(&active)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !active {
|
|
return errAccess
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (s *Service) checkCSRF(ctx context.Context, hash, token string) error {
|
|
if !validB64(token, 32) {
|
|
return errAccess
|
|
}
|
|
var expected string
|
|
err := s.pool.QueryRow(ctx, `SELECT csrf_hash FROM onboarding_web_session WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now()`, hash).Scan(&expected)
|
|
if err != nil || !hmac.Equal([]byte(expected), []byte(digest([]byte(token)))) {
|
|
return errAccess
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type ExchangeInput struct {
|
|
Token string `json:"token"`
|
|
Verifier string `json:"verifier"`
|
|
}
|
|
|
|
func (s *Service) Exchange(ctx context.Context, in ExchangeInput) (BrowserCredentials, error) {
|
|
var out BrowserCredentials
|
|
if !validB64(in.Token, 32) || !validB64(in.Verifier, 32) {
|
|
return out, errAccess
|
|
}
|
|
var deviceID string
|
|
err := s.pool.QueryRow(ctx, `SELECT ss.device_id::text FROM device_handoff h JOIN device_setup_session ss ON ss.id=h.setup_id WHERE h.token_hash=$1`, digest([]byte(in.Token))).Scan(&deviceID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return out, errAccess
|
|
}
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
d, err := readDevice(ctx, tx, deviceID, true)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if !deviceAllowed(d) {
|
|
return out, errAccess
|
|
}
|
|
var setupID, challenge string
|
|
var expires time.Time
|
|
var consumed *time.Time
|
|
err = tx.QueryRow(ctx, `SELECT setup_id::text,code_challenge,expires_at,consumed_at FROM device_handoff WHERE token_hash=$1 FOR UPDATE`, digest([]byte(in.Token))).Scan(&setupID, &challenge, &expires, &consumed)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
sum := sha256.Sum256([]byte(in.Verifier))
|
|
if consumed != nil || !expires.After(s.now()) || !hmac.Equal([]byte(challenge), []byte(encode64(sum[:]))) {
|
|
return out, fail(410, "HANDOFF_INVALID", "入口已失效,请从设备获取新入口")
|
|
}
|
|
setup, err := readSetup(ctx, tx, setupID, d, true)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if setup.Mode != "ENROLL" {
|
|
return out, errAccess
|
|
}
|
|
st, err := s.state(ctx, tx, d)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if st.BindingState == "BOUND" {
|
|
return out, errAccess
|
|
}
|
|
out.Token = randomToken(32)
|
|
out.CSRF = randomToken(32)
|
|
_, err = tx.Exec(ctx, `INSERT INTO onboarding_web_session(token_hash,setup_id,csrf_hash,expires_at) VALUES($1,$2::uuid,$3,$4)`, digest([]byte(out.Token)), setupID, digest([]byte(out.CSRF)), setup.Expires)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
_, err = tx.Exec(ctx, `UPDATE device_handoff SET consumed_at=now() WHERE token_hash=$1`, digest([]byte(in.Token)))
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
out.Data = map[string]any{"setup_id": setupID, "device_id": d.FactoryID, "state": st, "expires_at_ms": setup.Expires.UnixMilli(), "sms_enabled": s.options.SMSLoginEnabled}
|
|
return out, tx.Commit(ctx)
|
|
}
|
|
func (s *Service) attachPrincipal(ctx context.Context, tx pgx.Tx, w WebSession, d Device, p identity.Principal, allowSignup bool) (BrowserCredentials, error) {
|
|
var out BrowserCredentials
|
|
var user string
|
|
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
|
return out, err
|
|
}
|
|
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "account:"+d.TenantID+":"+p.Issuer+":"+p.Subject)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
err = tx.QueryRow(ctx, `SELECT id::text FROM user_account WHERE tenant_id=$1::uuid AND oidc_issuer=$2 AND oidc_subject=$3 AND status='active' FOR SHARE`, d.TenantID, p.Issuer, p.Subject).Scan(&user)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
var tenantPublic string
|
|
if err = tx.QueryRow(ctx, `SELECT public_id::text FROM tenant WHERE id=$1::uuid`, d.TenantID).Scan(&tenantPublic); err != nil {
|
|
return out, err
|
|
}
|
|
allowed := false
|
|
for _, id := range s.options.SelfSignupTenantIDs {
|
|
if id == tenantPublic {
|
|
allowed = true
|
|
}
|
|
}
|
|
if !allowSignup || !allowed {
|
|
return out, fail(403, "TENANT_MEMBERSHIP_REQUIRED", "请先由该租户管理员授权账号")
|
|
}
|
|
err = tx.QueryRow(ctx, `INSERT INTO user_account(tenant_id,oidc_issuer,oidc_subject,display_name,platform_role,account_kind) VALUES($1::uuid,$2,$3,$4,'customer','customer') RETURNING id::text`, d.TenantID, p.Issuer, p.Subject, p.DisplayName).Scan(&user)
|
|
}
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
out.Token = randomToken(32)
|
|
out.CSRF = randomToken(32)
|
|
out.AccountToken = randomToken(32)
|
|
_, err = tx.Exec(ctx, `INSERT INTO onboarding_web_session(token_hash,setup_id,csrf_hash,user_id,issuer,subject,expires_at) VALUES($1,$2::uuid,$3,$4::uuid,$5,$6,$7)`, digest([]byte(out.Token)), w.SetupID, digest([]byte(out.CSRF)), user, p.Issuer, p.Subject, w.Expires)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
_, err = tx.Exec(ctx, `UPDATE onboarding_web_session SET revoked_at=now() WHERE token_hash=$1`, w.Hash)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
_, err = tx.Exec(ctx, `INSERT INTO onboarding_account_session(token_hash,csrf_hash,user_id,expires_at) VALUES($1,$2,$3::uuid,$4)`, digest([]byte(out.AccountToken)), digest([]byte(out.CSRF)), user, s.now().Add(24*time.Hour))
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
out.Data = map[string]any{"authenticated": true, "setup_id": w.SetupID}
|
|
return out, nil
|
|
}
|
|
func (s *Service) AuthLink(ctx context.Context, w WebSession, p identity.Principal) (BrowserCredentials, error) {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return BrowserCredentials{}, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
d, err := readDevice(ctx, tx, w.Device.ID, true)
|
|
if err != nil {
|
|
return BrowserCredentials{}, err
|
|
}
|
|
out, err := s.attachPrincipal(ctx, tx, w, d, p, true)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
return out, tx.Commit(ctx)
|
|
}
|
|
|
|
type ClaimInput struct {
|
|
ElderID string `json:"elder_id"`
|
|
Generation int64 `json:"expected_binding_generation"`
|
|
}
|
|
|
|
func (s *Service) Claim(ctx context.Context, w WebSession, requestID string, raw []byte, in ClaimInput) (map[string]any, error) {
|
|
if w.UserID == "" {
|
|
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
|
|
}
|
|
if !uuidValid(in.ElderID) || in.Generation < 0 {
|
|
return nil, fail(400, "VALIDATION_FAILED", "请选择使用者")
|
|
}
|
|
return s.mutate(ctx, w.Device, "web:"+w.Hash, "claim", requestID, raw, 120*time.Second, func(tx pgx.Tx, d Device) (map[string]any, error) {
|
|
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
|
return nil, err
|
|
}
|
|
if d.Generation != in.Generation {
|
|
return nil, errState
|
|
}
|
|
setup, err := readSetup(ctx, tx, w.SetupID, d, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if setup.Mode != "ENROLL" {
|
|
return nil, errAccess
|
|
}
|
|
var household, subject string
|
|
err = tx.QueryRow(ctx, `SELECT id::text,household_id::text FROM subject_profile WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND status='active'`, in.ElderID, d.TenantID).Scan(&subject, &household)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, errAccess
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = claimPermission(ctx, tx, claimRecord{UserID: w.UserID, HouseholdID: household, SubjectID: subject, TenantID: d.TenantID}); err != nil {
|
|
return nil, err
|
|
}
|
|
st, err := s.state(ctx, tx, d)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if st.BindingState == "BOUND" {
|
|
return nil, errState
|
|
}
|
|
return newClaim(ctx, s, tx, d, setup.ID, w.UserID, household, subject)
|
|
})
|
|
}
|
|
|
|
type CancelInput struct {
|
|
ClaimID string `json:"claim_id"`
|
|
Generation int64 `json:"expected_binding_generation"`
|
|
}
|
|
|
|
func (s *Service) Cancel(ctx context.Context, w WebSession, requestID string, raw []byte, in CancelInput) (map[string]any, error) {
|
|
if !uuidV4(in.ClaimID) || in.Generation < 0 || w.UserID == "" {
|
|
return nil, errAccess
|
|
}
|
|
return s.mutate(ctx, w.Device, "web:"+w.Hash, "cancel", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
|
|
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
|
return nil, err
|
|
}
|
|
c, err := readClaim(ctx, tx, in.ClaimID, d.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if c.UserID != w.UserID || c.SetupID != w.SetupID {
|
|
return nil, errAccess
|
|
}
|
|
if err = claimPermission(ctx, tx, c); err != nil {
|
|
return nil, err
|
|
}
|
|
if c.Status == "CONFIRMED" && d.Generation == c.Generation+1 {
|
|
st, e := s.state(ctx, tx, d)
|
|
return map[string]any{"already_bound": true, "state": st}, e
|
|
}
|
|
if d.Generation != in.Generation {
|
|
return nil, errState
|
|
}
|
|
tag, err := tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE id=$1::uuid AND status='PENDING'`, c.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag.RowsAffected() > 0 {
|
|
if err = bump(ctx, tx, d.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
st, err := s.state(ctx, tx, d)
|
|
return map[string]any{"state": st}, err
|
|
})
|
|
}
|
|
func (s *Service) WebStatus(ctx context.Context, w WebSession) (map[string]any, error) {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
d, err := readDevice(ctx, tx, w.Device.ID, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !deviceAllowed(d) {
|
|
return nil, errAccess
|
|
}
|
|
setup, err := readSetup(ctx, tx, w.SetupID, d, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
st, err := s.state(ctx, tx, d)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var binding *string
|
|
var deviceSaved bool
|
|
var mine *string
|
|
if w.UserID != "" {
|
|
err = tx.QueryRow(ctx, `SELECT b.public_id::text,EXISTS(SELECT 1 FROM device_state_ack a WHERE a.device_id=b.device_id AND a.binding_generation=b.generation AND a.boot_id=$3::uuid) FROM device_binding b JOIN household_member hm ON hm.household_id=b.household_id AND hm.user_id=$2::uuid AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' WHERE b.device_id=$1::uuid AND b.unbound_at IS NULL`, d.ID, w.UserID, d.BootID).Scan(&binding, &deviceSaved)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
err = tx.QueryRow(ctx, `SELECT id::text FROM device_binding_claim WHERE setup_id=$1::uuid AND user_id=$2::uuid AND status='PENDING' AND expires_at>now()`, w.SetupID, w.UserID).Scan(&mine)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
}
|
|
// Never expose a different household's progress through an old handoff.
|
|
if st.BindingState == "BOUND" && binding == nil {
|
|
return nil, errAccess
|
|
}
|
|
return map[string]any{"setup_id": w.SetupID, "state": st, "binding_id": binding, "claim_id": mine, "device_saved": deviceSaved, "authenticated": w.UserID != "", "closed": setup.Closed != nil || !setup.Expires.After(s.now()), "sms_enabled": s.options.SMSLoginEnabled, "expires_at_ms": setup.Expires.UnixMilli()}, tx.Commit(ctx)
|
|
}
|
|
|
|
type WebAckInput struct {
|
|
BindingID string `json:"binding_id"`
|
|
Generation int64 `json:"binding_generation"`
|
|
Revision int64 `json:"received_revision"`
|
|
}
|
|
|
|
func (s *Service) WebAck(ctx context.Context, w WebSession, requestID string, raw []byte, in WebAckInput) (map[string]any, error) {
|
|
if !uuidValid(in.BindingID) || in.Generation < 1 || in.Revision < 1 || w.UserID == "" {
|
|
return nil, errAccess
|
|
}
|
|
return s.mutate(ctx, w.Device, "web:"+w.Hash, "web-ack", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
|
|
var bindingID string
|
|
err := tx.QueryRow(ctx, `SELECT b.id::text FROM device_binding b JOIN household_member hm ON hm.household_id=b.household_id AND hm.user_id=$3::uuid AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN device_state_snapshot ss ON ss.device_id=b.device_id AND ss.revision=$5 AND ss.binding_generation=b.generation AND ss.state->>'binding_state'='BOUND' WHERE b.device_id=$1::uuid AND b.public_id=$2::uuid AND b.unbound_at IS NULL AND b.generation=$4`, d.ID, in.BindingID, w.UserID, in.Generation, in.Revision).Scan(&bindingID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, errState
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if d.Generation != in.Generation {
|
|
return nil, errState
|
|
}
|
|
_, err = tx.Exec(ctx, `UPDATE device_setup_session SET web_ack_binding_id=$2::uuid,web_ack_generation=$3,web_ack_revision=$4,web_ack_at=now() WHERE id=$1::uuid`, w.SetupID, bindingID, in.Generation, in.Revision)
|
|
return map[string]any{"accepted": true}, err
|
|
})
|
|
}
|
|
func (s *Service) Profiles(ctx context.Context, w WebSession) (map[string]any, error) {
|
|
if w.UserID == "" {
|
|
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
|
|
}
|
|
rows, err := s.pool.Query(ctx, `SELECT h.public_id::text,h.name,sp.public_id::text,sp.preferred_name FROM household_member hm JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id AND h.status='active' LEFT JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id AND sp.status='active' WHERE hm.user_id=$1::uuid AND hm.tenant_id=$2::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') ORDER BY h.name,sp.preferred_name`, w.UserID, w.Device.TenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var h, n string
|
|
var id, name *string
|
|
if err = rows.Scan(&h, &n, &id, &name); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, map[string]any{"household_id": h, "household_name": n, "elder_id": id, "preferred_name": name})
|
|
}
|
|
return map[string]any{"items": items}, rows.Err()
|
|
}
|
|
|
|
type ProfileInput struct {
|
|
InitialConsents saas.InitialConsentSelection `json:"initial_consents,omitempty"`
|
|
HouseholdID string `json:"household_id,omitempty"`
|
|
HouseholdName string `json:"household_name,omitempty"`
|
|
Name string `json:"preferred_name"`
|
|
}
|
|
|
|
func (s *Service) CreateProfile(ctx context.Context, w WebSession, requestID string, raw []byte, in ProfileInput) (map[string]any, error) {
|
|
if err := saas.ValidateInitialConsents(in.InitialConsents); err != nil {
|
|
return nil, fail(400, "VALIDATION_FAILED", "请逐项选择授权并确认用途")
|
|
}
|
|
if w.UserID == "" || !nonempty(in.Name, 120) || (in.HouseholdID == "" && !nonempty(in.HouseholdName, 200)) || (in.HouseholdID != "" && !uuidValid(in.HouseholdID)) {
|
|
return nil, fail(400, "VALIDATION_FAILED", "家庭和使用者信息不正确")
|
|
}
|
|
return s.mutate(ctx, w.Device, "web:"+w.Hash, "profile", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
|
|
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
|
return nil, err
|
|
}
|
|
var household, public string
|
|
var err error
|
|
if in.HouseholdID == "" {
|
|
err = tx.QueryRow(ctx, `INSERT INTO household(tenant_id,name,owner_user_id) VALUES($1::uuid,$2,$3::uuid) RETURNING id::text,public_id::text`, d.TenantID, in.HouseholdName, w.UserID).Scan(&household, &public)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = tx.Exec(ctx, `INSERT INTO household_member(tenant_id,household_id,user_id,role) VALUES($1::uuid,$2::uuid,$3::uuid,'owner')`, d.TenantID, household, w.UserID)
|
|
} else {
|
|
err = tx.QueryRow(ctx, `SELECT h.id::text,h.public_id::text FROM household h JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id WHERE h.public_id=$1::uuid AND h.tenant_id=$2::uuid AND h.status='active' AND hm.user_id=$3::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') FOR SHARE OF h,hm`, in.HouseholdID, d.TenantID, w.UserID).Scan(&household, &public)
|
|
}
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, errAccess
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var id string
|
|
err = tx.QueryRow(ctx, `INSERT INTO subject_profile(tenant_id,household_id,preferred_name) VALUES($1::uuid,$2::uuid,$3) RETURNING public_id::text`, d.TenantID, household, in.Name).Scan(&id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = saas.InitializeSubjectMemory(ctx, tx, d.TenantID, id, w.UserID, requestID, in.InitialConsents); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = s.auditEvent(ctx, tx, d, "user", accountPublicID(ctx, tx, w.UserID), "onboarding.profile.create", requestID, map[string]any{"subject_id": id}); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"elder_id": id, "household_id": public}, nil
|
|
})
|
|
}
|
|
func accountPublicID(ctx context.Context, q queryer, id string) string {
|
|
var p string
|
|
_ = q.QueryRow(ctx, `SELECT public_id::text FROM user_account WHERE id=$1::uuid`, id).Scan(&p)
|
|
return p
|
|
}
|