feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
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.
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type BLETokenInput struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ElderID string `json:"elder_id,omitempty"`
|
||||
ExpectedBindingID string `json:"expected_binding_id,omitempty"`
|
||||
}
|
||||
type BLEBindInput struct {
|
||||
Token string `json:"bind_token"`
|
||||
}
|
||||
|
||||
type bleClaim struct {
|
||||
claimRecord
|
||||
AccountBindingID string
|
||||
BindingStatus string
|
||||
}
|
||||
|
||||
func readBLEClaim(ctx context.Context, tx pgx.Tx, token, deviceID string) (bleClaim, error) {
|
||||
var c bleClaim
|
||||
err := tx.QueryRow(ctx, `SELECT id::text,device_id::text,tenant_id::text,user_id::text,COALESCE(household_id::text,''),COALESCE(subject_id::text,''),expected_generation,status,expires_at,COALESCE(binding_id::text,''),COALESCE(account_binding_id::text,'') FROM device_ble_binding WHERE token_hash=$1 AND device_id=$2::uuid FOR UPDATE`, digest([]byte(token)), deviceID).Scan(&c.ID, &c.DeviceID, &c.TenantID, &c.UserID, &c.HouseholdID, &c.SubjectID, &c.Generation, &c.BindingStatus, &c.Expires, &c.BindingID, &c.AccountBindingID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c, fail(404, "BIND_TOKEN_INVALID", "绑定申请不存在或不可访问")
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (s *Service) bleWebHTTP(r *http.Request) (any, error) {
|
||||
if r.URL.RawQuery != "" {
|
||||
return nil, errAccess
|
||||
}
|
||||
p, err := s.browserPrincipal(r, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.limit(r.Context(), "ble-account:"+p.Issuer+":"+p.Subject, 90, time.Minute); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := readBody(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "ble-device-state") {
|
||||
return decodeCall(raw, func(in BLEDeviceInput) (map[string]any, error) {
|
||||
return s.BLEDeviceState(r.Context(), p, in)
|
||||
})
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "device-subject") {
|
||||
return decodeCall(raw, func(in AssignDeviceSubjectInput) (map[string]any, error) {
|
||||
return s.AssignDeviceSubject(r.Context(), p, r.Header.Get("X-Request-ID"), in)
|
||||
})
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "ble-bind-tokens") {
|
||||
var in BLETokenInput
|
||||
if err = StrictJSON(raw, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.CreateBLEToken(r.Context(), p, in)
|
||||
}
|
||||
var in BLEBindInput
|
||||
if err = StrictJSON(raw, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.BLEStatus(r.Context(), p, in)
|
||||
}
|
||||
|
||||
func (s *Service) CreateBLEToken(ctx context.Context, p identity.Principal, in BLETokenInput) (map[string]any, error) {
|
||||
if !s.Enabled() || s.options.PauseClaims {
|
||||
return nil, fail(503, "ENROLLMENT_PAUSED", "设备绑定暂时暂停")
|
||||
}
|
||||
if in.ElderID != "" && !uuidValid(in.ElderID) {
|
||||
return nil, fail(400, "ELDER_ID_INVALID", "老人编号格式不正确,请重新选择老人")
|
||||
}
|
||||
if in.ExpectedBindingID != "" && (!uuidValid(in.ExpectedBindingID) || in.ElderID == "") {
|
||||
return nil, fail(400, "BINDING_INVALID", "更换使用老人需要原绑定版本和新的老人")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if err = lockBLEControl(ctx, tx, "", in.DeviceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := readBLEDeviceAccess(ctx, tx, p, in.DeviceID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := a.Device
|
||||
var user string
|
||||
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' AND account_kind='customer' FOR SHARE`, d.TenantID, p.Issuer, p.Subject).Scan(&user)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bound bool
|
||||
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL) OR EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND released_at IS NULL)`, d.ID).Scan(&bound); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bound && in.ExpectedBindingID == "" {
|
||||
return nil, fail(409, "ALREADY_BOUND", "设备已有归属,无需重复添加;如需更换 Wi-Fi,请使用重新配网")
|
||||
}
|
||||
var sourceBindingID string
|
||||
if in.ExpectedBindingID != "" {
|
||||
err = tx.QueryRow(ctx, `SELECT id::text FROM device_binding WHERE device_id=$1::uuid AND public_id=$2::uuid AND unbound_at IS NULL`, d.ID, in.ExpectedBindingID).Scan(&sourceBindingID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fail(409, "BINDING_CHANGED", "设备归属已变化,请刷新后重新选择")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Omitting elder_id claims the device for this account without creating profiles.
|
||||
if in.ElderID == "" {
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND user_id=$2::uuid AND status='PENDING'`, d.ID, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := randomToken(32)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_ble_binding(token_hash,device_id,tenant_id,user_id,expected_generation,expires_at) VALUES($1,$2::uuid,$3::uuid,$4::uuid,$5,$6)`, digest([]byte(token)), d.ID, d.TenantID, user, d.Generation, s.now().Add(5*time.Minute))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"bind_token": token}, tx.Commit(ctx)
|
||||
}
|
||||
// Explicit elder_id retains the existing one-step binding contract.
|
||||
rows, err := tx.Query(ctx, `SELECT sp.id::text,sp.household_id::text FROM subject_profile sp JOIN household_member hm ON hm.household_id=sp.household_id AND hm.tenant_id=sp.tenant_id JOIN household h ON h.id=sp.household_id AND h.status='active' WHERE sp.tenant_id=$1::uuid AND hm.user_id=$2::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') AND sp.status='active' AND ($3='' OR sp.public_id::text=$3) ORDER BY sp.id LIMIT 2`, d.TenantID, user, in.ElderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var candidates []claimRecord
|
||||
for rows.Next() {
|
||||
c := claimRecord{UserID: user, TenantID: d.TenantID}
|
||||
if err = rows.Scan(&c.SubjectID, &c.HouseholdID); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
return nil, fail(400, "PROFILE_REQUIRED", "请选择一个有管理权限的使用者")
|
||||
}
|
||||
c := candidates[0]
|
||||
if err = claimPermission(ctx, tx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sourceBindingID != "" {
|
||||
if err = s.checkBLETransfer(ctx, tx, d, c, sourceBindingID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND user_id=$2::uuid AND status='PENDING'`, d.ID, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := randomToken(32)
|
||||
// For a pending transfer binding_id pins the source; commit replaces it with
|
||||
// the new binding. A normal add keeps it NULL until physical confirmation.
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_ble_binding(token_hash,device_id,tenant_id,user_id,household_id,subject_id,expected_generation,expires_at,binding_id) VALUES($1,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7,$8,NULLIF($9,'')::uuid)`, digest([]byte(token)), d.ID, d.TenantID, user, c.HouseholdID, c.SubjectID, d.Generation, s.now().Add(5*time.Minute), sourceBindingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"bind_token": token}, nil
|
||||
}
|
||||
|
||||
func (s *Service) BLEStatus(ctx context.Context, p identity.Principal, in BLEBindInput) (map[string]any, error) {
|
||||
if !validB64(in.Token, 32) {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "绑定令牌格式不正确")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var deviceID string
|
||||
err = tx.QueryRow(ctx, `SELECT b.device_id::text FROM device_ble_binding b JOIN user_account ua ON ua.id=b.user_id AND ua.status='active' WHERE b.token_hash=$1 AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, digest([]byte(in.Token)), p.Issuer, p.Subject).Scan(&deviceID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := readDevice(ctx, tx, deviceID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !deviceAllowed(d) {
|
||||
return nil, errAccess
|
||||
}
|
||||
c, err := readBLEClaim(ctx, tx, in.Token, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = bleClaimPermission(ctx, tx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status, err := bleResult(ctx, tx, d, c, s.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return status, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func bleResult(ctx context.Context, tx pgx.Tx, d Device, c bleClaim, now time.Time) (map[string]any, error) {
|
||||
if c.BindingStatus == "COMMITTED" && c.AccountBindingID != "" {
|
||||
var active bool
|
||||
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_account_binding ab WHERE ab.id=$1::uuid AND ab.device_id=$2::uuid AND ab.user_id=$3::uuid AND ((ab.released_at IS NULL AND ab.generation=$4) OR EXISTS(SELECT 1 FROM device_binding b WHERE b.id=ab.assigned_binding_id AND b.unbound_at IS NULL AND b.generation=$4)))`, c.AccountBindingID, d.ID, c.UserID, d.Generation).Scan(&active)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active {
|
||||
return map[string]any{"status": "success"}, nil
|
||||
}
|
||||
return map[string]any{"status": "failed", "message": "设备归属已变化,请刷新设备列表"}, nil
|
||||
}
|
||||
if c.BindingStatus == "COMMITTED" {
|
||||
var active bool
|
||||
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE id=$1::uuid AND device_id=$2::uuid AND unbound_at IS NULL AND generation=$3)`, c.BindingID, d.ID, c.Generation+1).Scan(&active)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active && d.Generation == c.Generation+1 {
|
||||
return map[string]any{"status": "success"}, nil
|
||||
}
|
||||
return map[string]any{"status": "failed", "message": "设备归属已变化,请刷新设备列表"}, nil
|
||||
}
|
||||
if c.BindingStatus != "PENDING" || !c.Expires.After(now) || c.Generation != d.Generation {
|
||||
return map[string]any{"status": "failed", "message": "绑定申请已过期或失效,请重新配网"}, nil
|
||||
}
|
||||
return map[string]any{"status": "pending"}, nil
|
||||
}
|
||||
|
||||
// Only an authenticated device calls this after its physical confirmation. The
|
||||
// request ID is the device's persisted confirmation event; no user-supplied owner
|
||||
// or Wi-Fi password crosses this boundary.
|
||||
func (s *Service) BindBLE(ctx context.Context, authenticated Device, requestID string, in BLEBindInput) (map[string]any, error) {
|
||||
result, err := s.commitBLEBinding(ctx, authenticated, requestID, in)
|
||||
if err != nil || s.options.LegacyDeviceRegistration {
|
||||
return result, err
|
||||
}
|
||||
// The binding is durable before any provider HTTP call. A retry of the same
|
||||
// token resumes this step without inserting another binding/registration.
|
||||
return s.finishBLEVoice(ctx, authenticated, in)
|
||||
}
|
||||
|
||||
func (s *Service) commitBLEBinding(ctx context.Context, authenticated Device, requestID string, in BLEBindInput) (map[string]any, error) {
|
||||
if !validB64(in.Token, 32) || !uuidV4(requestID) {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "绑定请求不正确")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if err = lockBLEControl(ctx, tx, authenticated.ID, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := readDevice(ctx, tx, authenticated.ID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !deviceAllowed(d) {
|
||||
return nil, errAccess
|
||||
}
|
||||
var keyActive bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_onboarding_key WHERE device_id=$1::uuid AND key_version=$2 AND status='ACTIVE')`, d.ID, authenticated.KeyVersion).Scan(&keyActive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !keyActive {
|
||||
return nil, errAuth
|
||||
}
|
||||
c, err := readBLEClaim(ctx, tx, in.Token, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = bleClaimPermission(ctx, tx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := bleResult(ctx, tx, d, c, s.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result["status"] == "success" {
|
||||
return result, tx.Commit(ctx)
|
||||
}
|
||||
if result["status"] != "pending" {
|
||||
return nil, fail(409, "BIND_TOKEN_EXPIRED", "绑定申请已过期或归属发生变化")
|
||||
}
|
||||
if !s.Enabled() || s.options.PauseClaims {
|
||||
return nil, fail(503, "ENROLLMENT_PAUSED", "设备绑定暂时暂停")
|
||||
}
|
||||
if c.BindingID != "" {
|
||||
return s.commitBLETransfer(ctx, tx, d, c, requestID, in.Token)
|
||||
}
|
||||
var active bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, d.ID).Scan(&active)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active {
|
||||
return nil, fail(409, "ALREADY_BOUND", "设备已有有效绑定")
|
||||
}
|
||||
if err = s.expireClaims(ctx, tx, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pending bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING')`, d.ID).Scan(&pending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pending {
|
||||
return nil, fail(409, "CLAIM_BUSY", "设备还有另一笔待确认绑定")
|
||||
}
|
||||
if c.SubjectID == "" {
|
||||
return s.commitBLEAccount(ctx, tx, d, c, requestID)
|
||||
}
|
||||
var owned bool
|
||||
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND released_at IS NULL)`, d.ID).Scan(&owned); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owned {
|
||||
return nil, fail(409, "ALREADY_BOUND", "设备已添加到账号,请从设备管理绑定老人")
|
||||
}
|
||||
result, err = s.commitBLESubject(ctx, tx, d, c, requestID, in.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) commitBLESubject(ctx context.Context, tx pgx.Tx, d Device, c bleClaim, requestID, token string) (map[string]any, error) {
|
||||
var err error
|
||||
// Reuse the existing database guard and activation pipeline. BLE does not
|
||||
// depend on a web handoff or a pre-network device boot registration.
|
||||
setupID, bootID := uuid.NewString(), d.BootID
|
||||
if bootID == "" {
|
||||
bootID = uuid.NewString()
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_setup_session(id,device_id,tenant_id,boot_id,local_setup_id,binding_generation,mode,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6,'ENROLL',$7)`, setupID, d.ID, d.TenantID, bootID, c.ID, d.Generation, c.Expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_binding_claim(id,device_id,tenant_id,setup_id,user_id,household_id,subject_id,expected_generation,challenge_hash,challenge_ciphertext,binding_digest,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7::uuid,$8,$9,$10,$11,$12)`, c.ID, d.ID, d.TenantID, setupID, c.UserID, c.HouseholdID, c.SubjectID, d.Generation, digest([]byte(token)), s.crypt.seal([]byte(token), "claim:"+c.ID), bindingDigest(d, c.UserID, c.HouseholdID, c.SubjectID, c.ID), c.Expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `SELECT set_config('app.onboarding_claim',$1,true)`, c.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bindingID string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,'ble_physical_confirm') RETURNING id::text`, d.TenantID, d.ID, c.HouseholdID, c.SubjectID, c.UserID).Scan(&bindingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_binding_claim SET status='CONFIRMED',press_event_id=$2::uuid,binding_id=$3::uuid,completed_at=now() WHERE id=$1::uuid`, c.ID, requestID, bindingID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='COMMITTED',binding_id=$2::uuid,completed_at=now() WHERE id=$1::uuid`, c.ID, bindingID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET binding_generation=$2,closed_at=now(),close_reason='BLE_BOUND' WHERE id=$1::uuid`, setupID, d.Generation+1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.Lifecycle == "CLAIMABLE" {
|
||||
if _, err = deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: d.ID, TenantID: d.TenantID, Target: deviceactivation.Bound, Reason: "蓝牙配网实体确认绑定", Source: "onboarding", ActorType: "device", ActorPublicID: &d.PublicID, RequestID: requestID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err = s.enqueueActivation(ctx, tx, d, d.Generation+1, requestID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) SELECT $1::uuid,$2::uuid,1,'{"voice_chat":true,"transcript_storage":false,"raw_audio_storage":false,"story_extraction":false,"personalized_memory":false,"family_sharing":false}'::jsonb,'[]'::jsonb WHERE NOT EXISTS(SELECT 1 FROM consent_snapshot WHERE tenant_id=$1::uuid AND subject_id=$2::uuid)`, d.TenantID, c.SubjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.AccountBindingID == "" {
|
||||
if err = s.auditEvent(ctx, tx, d, "device", d.PublicID, "device.binding.ble", requestID, map[string]any{"binding_generation": d.Generation + 1}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err = s.state(ctx, tx, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"status": "success"}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user