@rubytech/create-maxy-code 0.1.437 → 0.1.439
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/lib/admin-access-password/__tests__/index.test.ts +31 -0
- package/payload/platform/lib/admin-access-password/dist/index.d.ts +8 -0
- package/payload/platform/lib/admin-access-password/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/admin-access-password/dist/index.js +5 -1
- package/payload/platform/lib/admin-access-password/dist/index.js.map +1 -1
- package/payload/platform/lib/admin-access-password/src/index.ts +14 -1
- package/payload/platform/lib/admins-write/__tests__/index.test.ts +27 -0
- package/payload/platform/lib/admins-write/dist/index.d.ts +8 -0
- package/payload/platform/lib/admins-write/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/admins-write/dist/index.js +5 -1
- package/payload/platform/lib/admins-write/dist/index.js.map +1 -1
- package/payload/platform/lib/admins-write/src/index.ts +14 -1
- package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +4 -2
- package/payload/platform/plugins/admin/mcp/dist/credential-identity.d.ts +23 -0
- package/payload/platform/plugins/admin/mcp/dist/credential-identity.d.ts.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/credential-identity.js +35 -0
- package/payload/platform/plugins/admin/mcp/dist/credential-identity.js.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js +110 -57
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/tools/account-lifecycle.d.ts +22 -0
- package/payload/platform/plugins/admin/mcp/dist/tools/account-lifecycle.d.ts.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/tools/account-lifecycle.js +62 -2
- package/payload/platform/plugins/admin/mcp/dist/tools/account-lifecycle.js.map +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +7 -1
- package/payload/platform/plugins/docs/references/admin-identity-gate.md +40 -0
- package/payload/platform/plugins/docs/references/internals.md +4 -0
- package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
- package/payload/platform/plugins/graph-viewer/.claude-plugin/plugin.json +13 -0
- package/payload/platform/plugins/graph-viewer/PLUGIN.md +1 -1
- package/payload/platform/plugins/graph-viewer/lib/mcp-spawn-tee/index.js +193 -0
- package/payload/platform/plugins/graph-viewer/lib/mcp-spawn-tee/package.json +3 -0
- package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/profile.md +1 -1
- package/payload/platform/scripts/remediate-glsmith-identity.sh +500 -0
- package/payload/platform/scripts/seed-neo4j.sh +77 -12
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +20 -4
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/identity-audit.d.ts +51 -0
- package/payload/platform/services/claude-session-manager/dist/identity-audit.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/identity-audit.js +103 -0
- package/payload/platform/services/claude-session-manager/dist/identity-audit.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/index.js +69 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/mcp-user-binding.d.ts +25 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-user-binding.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-user-binding.js +42 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-user-binding.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/redundant-operator-seat-audit.d.ts +44 -0
- package/payload/platform/services/claude-session-manager/dist/redundant-operator-seat-audit.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/redundant-operator-seat-audit.js +127 -0
- package/payload/platform/services/claude-session-manager/dist/redundant-operator-seat-audit.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.d.ts +57 -0
- package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.js +147 -0
- package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.js.map +1 -0
- package/payload/server/{chunk-LMLVOZ6G.js → chunk-UGPQCVIW.js} +11 -38
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/server.js +15 -16
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================
|
|
3
|
+
# remediate-glsmith-identity.sh
|
|
4
|
+
#
|
|
5
|
+
# Purpose
|
|
6
|
+
# -------
|
|
7
|
+
# One-account identity-corner repair for G.L.Smith & Sons (accountId
|
|
8
|
+
# 2078cb54) on the SiteDesk Code install (Neo4j bolt://localhost:7689).
|
|
9
|
+
# Merges a fragmented owner/admin/person corner into one canonical named
|
|
10
|
+
# owner. Task 1569. Design + grounded live state:
|
|
11
|
+
# maxy-code/.docs/migrations/2026-07-glsmith-identity-remediation.md
|
|
12
|
+
#
|
|
13
|
+
# What it does (each step idempotent; logs its post-condition):
|
|
14
|
+
# rename-owner 3f81e7c2 "Owner" -> name "Dale Smith"
|
|
15
|
+
# rehome-prefs move 28 source:explicit prefs from stray profile
|
|
16
|
+
# (userId 436fdf2d, acct 2078cb54) to the owner profile,
|
|
17
|
+
# re-stamp pref.userId -> 3f81e7c2; delete the 4
|
|
18
|
+
# source:behavioural prefs; remove the emptied stray
|
|
19
|
+
# profile (clears its cross-account HAS_PROFILE edge)
|
|
20
|
+
# merge-person merge the second Dale Person into the owner's Dale
|
|
21
|
+
# Person (keep phone 447958391734); one named Dale left
|
|
22
|
+
# fold-dale-seat re-point 2f4a0a6b's inbound STARTED_BY to the owner,
|
|
23
|
+
# drop its OWNS, remove its empty profile, delete it
|
|
24
|
+
# retire-joel-seat delete c6a0e01a + its empty profile; drop the stray
|
|
25
|
+
# cross-account OWNS -> Person 324 (house Person 324 kept)
|
|
26
|
+
# keith-timeline create a Task RAISED_BY the owner Dale Person and
|
|
27
|
+
# AFFECTS Person Keith Bullot (759), from Event 1559
|
|
28
|
+
# account-admins account.json.admins -> [] (access is install-wide)
|
|
29
|
+
# reembed-needed report nodes needing a nomic-embed-text re-embed
|
|
30
|
+
#
|
|
31
|
+
# Write-gate invariant: the account never drops below one
|
|
32
|
+
# AdminUser{accountId} and always retains its role:'owner' node. Asserted
|
|
33
|
+
# before and after every destructive step.
|
|
34
|
+
#
|
|
35
|
+
# Usage
|
|
36
|
+
# -----
|
|
37
|
+
# bash remediate-glsmith-identity.sh # dry-run (default)
|
|
38
|
+
# bash remediate-glsmith-identity.sh --dry-run
|
|
39
|
+
# bash remediate-glsmith-identity.sh --verify # post-apply asserts
|
|
40
|
+
# bash remediate-glsmith-identity.sh --apply --dump=/path/to/neo4j.dump
|
|
41
|
+
# bash remediate-glsmith-identity.sh --apply --force-no-dump # no dump (loud)
|
|
42
|
+
#
|
|
43
|
+
# --dry-run Print every intended node/edge change; mutate nothing (default).
|
|
44
|
+
# --apply Perform the writes. Refuses to run without a valid --dump path
|
|
45
|
+
# (a fresh full neo4j-admin dump) unless --force-no-dump is given.
|
|
46
|
+
# Writes a JSON snapshot of every touched node/rel before writing.
|
|
47
|
+
# --verify Run the post-apply asserts; exit non-zero if any fail.
|
|
48
|
+
#
|
|
49
|
+
# Rollback
|
|
50
|
+
# --------
|
|
51
|
+
# Authoritative: restore the operator's --dump via `neo4j-admin database
|
|
52
|
+
# load`. Fast/targeted: the JSON snapshot at ~/glsmith-1569-snapshot-<ts>.{nodes,rels}
|
|
53
|
+
# records the touched nodes (elementId, labels, properties) and relationships
|
|
54
|
+
# (type, endpoints, properties) as they were before the writes.
|
|
55
|
+
#
|
|
56
|
+
# Reads:
|
|
57
|
+
# ~/sitedesk-code/platform/config/.neo4j-password Neo4j password
|
|
58
|
+
# ~/sitedesk-code/data/accounts/2078cb54-.../account.json
|
|
59
|
+
# ============================================================
|
|
60
|
+
|
|
61
|
+
set -euo pipefail
|
|
62
|
+
|
|
63
|
+
# --- Fixed identity constants (account 2078cb54) --------------------
|
|
64
|
+
readonly ACCT='2078cb54-08e9-49e9-bf8e-e9f3ad76ca41'
|
|
65
|
+
readonly OWNER='3f81e7c2-8e38-42f0-bce2-7355cd7c1ecf' # survivor, role:owner
|
|
66
|
+
readonly JOEL='c6a0e01a-ca12-4c6c-b8a9-0d5be937136b' # operator seat, retire
|
|
67
|
+
readonly DALE='2f4a0a6b-f239-461b-ba90-0d380fc72f43' # sub-account seat, fold
|
|
68
|
+
readonly HOUSEOP='436fdf2d-cfa7-4836-9ffa-77d91cef15f9' # house operator; stray profile userId
|
|
69
|
+
readonly DALE_PHONE='447958391734'
|
|
70
|
+
|
|
71
|
+
readonly PWFILE="$HOME/sitedesk-code/platform/config/.neo4j-password"
|
|
72
|
+
readonly ACCOUNT_JSON="$HOME/sitedesk-code/data/accounts/$ACCT/account.json"
|
|
73
|
+
readonly NEO4J_USER="${NEO4J_USER:-neo4j}"
|
|
74
|
+
readonly URI="${NEO4J_URI:-bolt://localhost:7689}"
|
|
75
|
+
|
|
76
|
+
MODE="dry-run"
|
|
77
|
+
DUMP_PATH=""
|
|
78
|
+
FORCE_NO_DUMP=0
|
|
79
|
+
|
|
80
|
+
while [ $# -gt 0 ]; do
|
|
81
|
+
case "$1" in
|
|
82
|
+
--dry-run) MODE="dry-run"; shift ;;
|
|
83
|
+
--apply) MODE="apply"; shift ;;
|
|
84
|
+
--verify) MODE="verify"; shift ;;
|
|
85
|
+
--dump=*) DUMP_PATH="${1#*=}"; shift ;;
|
|
86
|
+
--dump) DUMP_PATH="${2:-}"; shift 2 ;;
|
|
87
|
+
--force-no-dump) FORCE_NO_DUMP=1; shift ;;
|
|
88
|
+
-h|--help) sed -n '2,/^# =\{20,\}/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
89
|
+
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
|
90
|
+
esac
|
|
91
|
+
done
|
|
92
|
+
|
|
93
|
+
log() { echo "[id-remediation] $*" >&2; }
|
|
94
|
+
|
|
95
|
+
# --- Connection -----------------------------------------------------
|
|
96
|
+
[ -f "$PWFILE" ] || { echo "Error: $PWFILE missing (run on the SiteDesk Code box)" >&2; exit 1; }
|
|
97
|
+
command -v cypher-shell >/dev/null 2>&1 || { echo "Error: cypher-shell not in PATH" >&2; exit 1; }
|
|
98
|
+
PW="$(cat "$PWFILE")"
|
|
99
|
+
|
|
100
|
+
# run_cypher: reads a cypher statement on stdin, prints --format plain output.
|
|
101
|
+
run_cypher() { cypher-shell -u "$NEO4J_USER" -p "$PW" -a "$URI" --format plain; }
|
|
102
|
+
|
|
103
|
+
# scalar: first data value (row 2), quotes stripped. Always exits 0 (empty result
|
|
104
|
+
# yields empty string) so a `set -e` assignment never aborts on no-rows.
|
|
105
|
+
scalar() { { tail -n +2 | tr -d '"' | grep -v '^[[:space:]]*$' | head -1; } || true; }
|
|
106
|
+
# int: last bare-integer token, defaulting 0.
|
|
107
|
+
as_int() { awk -F',' '{for(i=1;i<=NF;i++){gsub(/[^0-9-]/,"",$i); if($i ~ /^-?[0-9]+$/) v=$i}} END{print v+0}'; }
|
|
108
|
+
|
|
109
|
+
# --- Write-gate --------------------------------------------------------
|
|
110
|
+
# Echoes "admins=<n> owners=<n> business=<0|1>"; returns 0 iff n>=1 && owners>=1.
|
|
111
|
+
write_gate() {
|
|
112
|
+
local out n owners biz
|
|
113
|
+
out="$(run_cypher <<CYPHER
|
|
114
|
+
MATCH (a:AdminUser {accountId:'$ACCT'})
|
|
115
|
+
WITH count(a) AS n, sum(CASE WHEN a.role='owner' THEN 1 ELSE 0 END) AS owners
|
|
116
|
+
OPTIONAL MATCH (b:LocalBusiness {accountId:'$ACCT'})
|
|
117
|
+
RETURN n, owners, CASE WHEN count(b) > 0 THEN 1 ELSE 0 END AS business;
|
|
118
|
+
CYPHER
|
|
119
|
+
)"
|
|
120
|
+
n="$(echo "$out" | tail -n +2 | awk -F',' '{print $1+0}')"
|
|
121
|
+
owners="$(echo "$out" | tail -n +2 | awk -F',' '{print $2+0}')"
|
|
122
|
+
biz="$(echo "$out" | tail -n +2 | awk -F',' '{print $3+0}')"
|
|
123
|
+
echo "admins=$n owners=$owners business=$biz"
|
|
124
|
+
[ "$n" -ge 1 ] && [ "$owners" -ge 1 ]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
assert_write_gate() {
|
|
128
|
+
local g; g="$(write_gate)" || { log "ABORT write-gate breached: $g"; exit 3; }
|
|
129
|
+
case "$g" in *business=1*) : ;; *) log "ABORT write-gate: no LocalBusiness ($g)"; exit 3 ;; esac
|
|
130
|
+
log "write-gate ok $g"
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# --- Preconditions -----------------------------------------------------
|
|
134
|
+
preconditions() {
|
|
135
|
+
run_cypher <<< "RETURN 1;" >/dev/null 2>&1 || { echo "Error: cannot connect to $URI" >&2; exit 1; }
|
|
136
|
+
local ownerName
|
|
137
|
+
ownerName="$(run_cypher <<CYPHER | scalar
|
|
138
|
+
MATCH (a:AdminUser {userId:'$OWNER', accountId:'$ACCT', role:'owner'}) RETURN a.name;
|
|
139
|
+
CYPHER
|
|
140
|
+
)"
|
|
141
|
+
[ -n "$ownerName" ] || { log "ABORT precondition: survivor owner $OWNER (role:owner) not found on $ACCT"; exit 3; }
|
|
142
|
+
assert_write_gate
|
|
143
|
+
log "preconditions ok survivorOwner=$OWNER name=\"$ownerName\""
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# ======================================================================
|
|
147
|
+
# Steps. Each runs a read-only DETECT (always, prints WOULD-change) and,
|
|
148
|
+
# when MODE=apply, the mutation. Idempotent: DETECT reports 0 -> no-op.
|
|
149
|
+
# ======================================================================
|
|
150
|
+
|
|
151
|
+
step_rename_owner() {
|
|
152
|
+
local pending
|
|
153
|
+
pending="$(run_cypher <<CYPHER | as_int
|
|
154
|
+
MATCH (a:AdminUser {userId:'$OWNER', accountId:'$ACCT'}) WHERE a.name <> 'Dale Smith' RETURN count(a);
|
|
155
|
+
CYPHER
|
|
156
|
+
)"
|
|
157
|
+
if [ "$pending" -eq 0 ]; then log "step=rename-owner no-op (already 'Dale Smith')"; return; fi
|
|
158
|
+
if [ "$MODE" = "dry-run" ]; then log "step=rename-owner WOULD set AdminUser $OWNER name -> 'Dale Smith'"; return; fi
|
|
159
|
+
run_cypher <<CYPHER >/dev/null
|
|
160
|
+
MATCH (a:AdminUser {userId:'$OWNER', accountId:'$ACCT'}) SET a.name='Dale Smith', a.updatedAt=timestamp();
|
|
161
|
+
CYPHER
|
|
162
|
+
log "step=rename-owner name=\"Dale Smith\""
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
step_rehome_prefs() {
|
|
166
|
+
local explicit behavioural
|
|
167
|
+
explicit="$(run_cypher <<CYPHER | as_int
|
|
168
|
+
MATCH (:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'})-[:HAS_PREFERENCE]->(p:Preference) WHERE p.source='explicit' RETURN count(p);
|
|
169
|
+
CYPHER
|
|
170
|
+
)"
|
|
171
|
+
behavioural="$(run_cypher <<CYPHER | as_int
|
|
172
|
+
MATCH (:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'})-[:HAS_PREFERENCE]->(p:Preference) WHERE p.source='behavioural' RETURN count(p);
|
|
173
|
+
CYPHER
|
|
174
|
+
)"
|
|
175
|
+
if [ "$explicit" -eq 0 ] && [ "$behavioural" -eq 0 ]; then
|
|
176
|
+
local strayGone
|
|
177
|
+
strayGone="$(run_cypher <<CYPHER | as_int
|
|
178
|
+
MATCH (u:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'}) RETURN count(u);
|
|
179
|
+
CYPHER
|
|
180
|
+
)"
|
|
181
|
+
[ "$strayGone" -eq 0 ] && { log "step=rehome-prefs no-op (stray profile already removed)"; return; }
|
|
182
|
+
fi
|
|
183
|
+
if [ "$MODE" = "dry-run" ]; then
|
|
184
|
+
local ownerNow
|
|
185
|
+
ownerNow="$(run_cypher <<CYPHER | as_int
|
|
186
|
+
MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:HAS_PROFILE]->(op:UserProfile)-[:HAS_PREFERENCE]->(p) RETURN count(p);
|
|
187
|
+
CYPHER
|
|
188
|
+
)"
|
|
189
|
+
log "step=rehome-prefs WOULD move explicit=$explicit -> owner profile (now has $ownerNow); delete behavioural=$behavioural; remove stray profile"
|
|
190
|
+
return
|
|
191
|
+
fi
|
|
192
|
+
# Move explicit prefs to the owner profile, re-stamp userId.
|
|
193
|
+
local moved
|
|
194
|
+
moved="$(run_cypher <<CYPHER | as_int
|
|
195
|
+
MATCH (stray:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'})-[hp:HAS_PREFERENCE]->(p:Preference) WHERE p.source='explicit'
|
|
196
|
+
MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:HAS_PROFILE]->(op:UserProfile)
|
|
197
|
+
MERGE (op)-[:HAS_PREFERENCE]->(p)
|
|
198
|
+
DELETE hp
|
|
199
|
+
SET p.userId='$OWNER', p.updatedAt=timestamp()
|
|
200
|
+
RETURN count(p);
|
|
201
|
+
CYPHER
|
|
202
|
+
)"
|
|
203
|
+
# Delete behavioural prefs.
|
|
204
|
+
local dropped
|
|
205
|
+
dropped="$(run_cypher <<CYPHER | as_int
|
|
206
|
+
MATCH (:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'})-[:HAS_PREFERENCE]->(p:Preference) WHERE p.source='behavioural'
|
|
207
|
+
DETACH DELETE p RETURN count(p);
|
|
208
|
+
CYPHER
|
|
209
|
+
)"
|
|
210
|
+
# Remove the now-empty stray profile (clears cross-account HAS_PROFILE).
|
|
211
|
+
local strayRemoved
|
|
212
|
+
strayRemoved="$(run_cypher <<CYPHER | as_int
|
|
213
|
+
MATCH (stray:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'}) WHERE NOT (stray)-[:HAS_PREFERENCE]->()
|
|
214
|
+
DETACH DELETE stray RETURN count(*);
|
|
215
|
+
CYPHER
|
|
216
|
+
)"
|
|
217
|
+
local ownerPrefs
|
|
218
|
+
ownerPrefs="$(run_cypher <<CYPHER | as_int
|
|
219
|
+
MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:HAS_PROFILE]->(op:UserProfile)-[:HAS_PREFERENCE]->(p) RETURN count(p);
|
|
220
|
+
CYPHER
|
|
221
|
+
)"
|
|
222
|
+
log "step=rehome-prefs moved=$moved droppedBehavioural=$dropped strayRemoved=$strayRemoved ownerProfilePrefs=$ownerPrefs"
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
step_merge_person() {
|
|
226
|
+
# Survivor = the Dale Person carrying the phone + WhatsApp SENT history (496).
|
|
227
|
+
# Loser = the bare owner-linked Dale stub (338), no phone, no outbound.
|
|
228
|
+
# We re-point the loser's inbound OWNS and RAISED_BY onto the survivor, then
|
|
229
|
+
# delete the stub. This preserves the survivor's hundreds of SENT->Message
|
|
230
|
+
# edges intact (never delete the message-history node).
|
|
231
|
+
local dalePersons stubCount
|
|
232
|
+
dalePersons="$(run_cypher <<CYPHER | as_int
|
|
233
|
+
MATCH (p:Person {accountId:'$ACCT'}) WHERE p.givenName='Dale' AND p.familyName='Smith' RETURN count(p);
|
|
234
|
+
CYPHER
|
|
235
|
+
)"
|
|
236
|
+
if [ "$dalePersons" -le 1 ]; then log "step=merge-person no-op (one Dale Person already)"; return; fi
|
|
237
|
+
# A loser is a BARE Dale stub only: not the phone-holder, no phone of its own,
|
|
238
|
+
# and no SENT history. A real Dale contact with its own phone or messages can
|
|
239
|
+
# never be the loser, so a name collision is left untouched. The grounded
|
|
240
|
+
# premise is exactly one such stub (338); abort on any other count.
|
|
241
|
+
stubCount="$(run_cypher <<CYPHER | as_int
|
|
242
|
+
MATCH (surv:Person {accountId:'$ACCT'}) WHERE surv.givenName='Dale' AND surv.familyName='Smith' AND surv.telephone='$DALE_PHONE'
|
|
243
|
+
MATCH (loser:Person {accountId:'$ACCT'}) WHERE loser.givenName='Dale' AND loser.familyName='Smith' AND elementId(loser) <> elementId(surv) AND loser.telephone IS NULL AND NOT (loser)-[:SENT]->(:Message)
|
|
244
|
+
RETURN count(loser);
|
|
245
|
+
CYPHER
|
|
246
|
+
)"
|
|
247
|
+
if [ "$stubCount" -ne 1 ]; then
|
|
248
|
+
log "step=merge-person ABORT expected exactly one bare Dale stub to merge, found $stubCount (dalePersons=$dalePersons); state drift, inspect before applying"
|
|
249
|
+
exit 3
|
|
250
|
+
fi
|
|
251
|
+
if [ "$MODE" = "dry-run" ]; then
|
|
252
|
+
log "step=merge-person WOULD keep the Dale Person with phone $DALE_PHONE + message history (survivor), re-point the bare stub's OWNS + RAISED_BY onto it, then delete the stub"
|
|
253
|
+
return
|
|
254
|
+
fi
|
|
255
|
+
local movedOwns movedRaised
|
|
256
|
+
movedOwns="$(run_cypher <<CYPHER | as_int
|
|
257
|
+
MATCH (surv:Person {accountId:'$ACCT'}) WHERE surv.givenName='Dale' AND surv.familyName='Smith' AND surv.telephone='$DALE_PHONE'
|
|
258
|
+
MATCH (loser:Person {accountId:'$ACCT'}) WHERE loser.givenName='Dale' AND loser.familyName='Smith' AND elementId(loser) <> elementId(surv) AND loser.telephone IS NULL AND NOT (loser)-[:SENT]->(:Message)
|
|
259
|
+
MATCH (a)-[o:OWNS]->(loser)
|
|
260
|
+
MERGE (a)-[:OWNS]->(surv)
|
|
261
|
+
DELETE o
|
|
262
|
+
RETURN count(*);
|
|
263
|
+
CYPHER
|
|
264
|
+
)"
|
|
265
|
+
movedRaised="$(run_cypher <<CYPHER | as_int
|
|
266
|
+
MATCH (surv:Person {accountId:'$ACCT'}) WHERE surv.givenName='Dale' AND surv.familyName='Smith' AND surv.telephone='$DALE_PHONE'
|
|
267
|
+
MATCH (loser:Person {accountId:'$ACCT'}) WHERE loser.givenName='Dale' AND loser.familyName='Smith' AND elementId(loser) <> elementId(surv) AND loser.telephone IS NULL AND NOT (loser)-[:SENT]->(:Message)
|
|
268
|
+
MATCH (t)-[r:RAISED_BY]->(loser)
|
|
269
|
+
MERGE (t)-[:RAISED_BY]->(surv)
|
|
270
|
+
DELETE r
|
|
271
|
+
RETURN count(*);
|
|
272
|
+
CYPHER
|
|
273
|
+
)"
|
|
274
|
+
# Delete the now-detached bare stub (same narrow predicate: never the survivor,
|
|
275
|
+
# never a Dale with its own phone or message history).
|
|
276
|
+
local deleted
|
|
277
|
+
deleted="$(run_cypher <<CYPHER | as_int
|
|
278
|
+
MATCH (surv:Person {accountId:'$ACCT'}) WHERE surv.givenName='Dale' AND surv.familyName='Smith' AND surv.telephone='$DALE_PHONE'
|
|
279
|
+
MATCH (loser:Person {accountId:'$ACCT'}) WHERE loser.givenName='Dale' AND loser.familyName='Smith' AND elementId(loser) <> elementId(surv) AND loser.telephone IS NULL AND NOT (loser)-[:SENT]->(:Message)
|
|
280
|
+
DETACH DELETE loser
|
|
281
|
+
RETURN count(*);
|
|
282
|
+
CYPHER
|
|
283
|
+
)"
|
|
284
|
+
local sent ownerOwns
|
|
285
|
+
sent="$(run_cypher <<CYPHER | as_int
|
|
286
|
+
MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:OWNS]->(p:Person)-[:SENT]->(:Message) RETURN count(*);
|
|
287
|
+
CYPHER
|
|
288
|
+
)"
|
|
289
|
+
ownerOwns="$(run_cypher <<CYPHER | as_int
|
|
290
|
+
MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:OWNS]->(p:Person) RETURN count(p);
|
|
291
|
+
CYPHER
|
|
292
|
+
)"
|
|
293
|
+
log "step=merge-person survivor=phone+history stubsDeleted=$deleted ownsMoved=$movedOwns raisedByMoved=$movedRaised ownerOwnsPersons=$ownerOwns survivorSentEdges=$sent"
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
step_fold_dale_seat() {
|
|
297
|
+
local present
|
|
298
|
+
present="$(run_cypher <<CYPHER | as_int
|
|
299
|
+
MATCH (a:AdminUser {userId:'$DALE', accountId:'$ACCT'}) RETURN count(a);
|
|
300
|
+
CYPHER
|
|
301
|
+
)"
|
|
302
|
+
if [ "$present" -eq 0 ]; then log "step=fold-dale-seat no-op (seat already folded)"; return; fi
|
|
303
|
+
local startedBy
|
|
304
|
+
startedBy="$(run_cypher <<CYPHER | as_int
|
|
305
|
+
MATCH (x)-[:STARTED_BY]->(a:AdminUser {userId:'$DALE', accountId:'$ACCT'}) RETURN count(x);
|
|
306
|
+
CYPHER
|
|
307
|
+
)"
|
|
308
|
+
if [ "$MODE" = "dry-run" ]; then log "step=fold-dale-seat WOULD re-point startedBy=$startedBy to owner, remove empty profile, delete seat $DALE"; return; fi
|
|
309
|
+
# Re-point inbound STARTED_BY sessions to the owner.
|
|
310
|
+
local moved
|
|
311
|
+
moved="$(run_cypher <<CYPHER | as_int
|
|
312
|
+
MATCH (x)-[r:STARTED_BY]->(dale:AdminUser {userId:'$DALE', accountId:'$ACCT'})
|
|
313
|
+
MATCH (owner:AdminUser {userId:'$OWNER', accountId:'$ACCT'})
|
|
314
|
+
MERGE (x)-[:STARTED_BY]->(owner)
|
|
315
|
+
DELETE r
|
|
316
|
+
RETURN count(x);
|
|
317
|
+
CYPHER
|
|
318
|
+
)"
|
|
319
|
+
# Remove the empty profile (guarded: only if it holds no preferences), then the
|
|
320
|
+
# seat (DETACH DELETE drops its OWNS edges).
|
|
321
|
+
run_cypher <<CYPHER >/dev/null
|
|
322
|
+
MATCH (dale:AdminUser {userId:'$DALE', accountId:'$ACCT'})-[:HAS_PROFILE]->(dp:UserProfile)
|
|
323
|
+
WHERE NOT (dp)-[:HAS_PREFERENCE]->()
|
|
324
|
+
DETACH DELETE dp;
|
|
325
|
+
CYPHER
|
|
326
|
+
run_cypher <<CYPHER >/dev/null
|
|
327
|
+
MATCH (dale:AdminUser {userId:'$DALE', accountId:'$ACCT'}) DETACH DELETE dale;
|
|
328
|
+
CYPHER
|
|
329
|
+
assert_write_gate
|
|
330
|
+
local surviving; surviving="$(write_gate | sed 's/.*admins=\([0-9]*\).*/\1/')"
|
|
331
|
+
log "step=fold-dale-seat startedByMoved=$moved seatRemoved=1 survivingAdminUsers=$surviving"
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
step_retire_joel_seat() {
|
|
335
|
+
local present
|
|
336
|
+
present="$(run_cypher <<CYPHER | as_int
|
|
337
|
+
MATCH (a:AdminUser {userId:'$JOEL', accountId:'$ACCT'}) RETURN count(a);
|
|
338
|
+
CYPHER
|
|
339
|
+
)"
|
|
340
|
+
if [ "$present" -eq 0 ]; then log "step=retire-joel-seat no-op (seat already retired)"; return; fi
|
|
341
|
+
if [ "$MODE" = "dry-run" ]; then log "step=retire-joel-seat WOULD delete seat $JOEL + empty profile, drop stray cross-account OWNS -> house Person 324 (324 kept)"; return; fi
|
|
342
|
+
run_cypher <<CYPHER >/dev/null
|
|
343
|
+
MATCH (joel:AdminUser {userId:'$JOEL', accountId:'$ACCT'})-[:HAS_PROFILE]->(jp:UserProfile)
|
|
344
|
+
WHERE NOT (jp)-[:HAS_PREFERENCE]->()
|
|
345
|
+
DETACH DELETE jp;
|
|
346
|
+
CYPHER
|
|
347
|
+
run_cypher <<CYPHER >/dev/null
|
|
348
|
+
MATCH (joel:AdminUser {userId:'$JOEL', accountId:'$ACCT'}) DETACH DELETE joel;
|
|
349
|
+
CYPHER
|
|
350
|
+
assert_write_gate
|
|
351
|
+
local surviving; surviving="$(write_gate | sed 's/.*admins=\([0-9]*\).*/\1/')"
|
|
352
|
+
log "step=retire-joel-seat seatRemoved=1 housePersonRetained=324 survivingAdminUsers=$surviving"
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
step_keith_timeline() {
|
|
356
|
+
local exists
|
|
357
|
+
exists="$(run_cypher <<CYPHER | as_int
|
|
358
|
+
MATCH (t:Task {remediationKey:'1569-keith-bathroom-tap', accountId:'$ACCT'}) RETURN count(t);
|
|
359
|
+
CYPHER
|
|
360
|
+
)"
|
|
361
|
+
if [ "$exists" -gt 0 ]; then log "step=keith-timeline no-op (task already present)"; return; fi
|
|
362
|
+
if [ "$MODE" = "dry-run" ]; then log "step=keith-timeline WOULD create Task RAISED_BY owner Dale Person, AFFECTS Person Keith Bullot (759)"; return; fi
|
|
363
|
+
local taskId
|
|
364
|
+
taskId="$(run_cypher <<CYPHER | scalar
|
|
365
|
+
MATCH (owner:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:OWNS]->(dale:Person)
|
|
366
|
+
MATCH (keith:Person {accountId:'$ACCT'}) WHERE keith.givenName='Keith' AND keith.familyName='Bullot'
|
|
367
|
+
MERGE (t:Task {remediationKey:'1569-keith-bathroom-tap', accountId:'$ACCT'})
|
|
368
|
+
ON CREATE SET t.taskId=randomUUID(), t.name='Text Keith Bullot about the bathroom tap',
|
|
369
|
+
t.description='Contact Keith Bullot regarding the bathroom tap.', t.status='pending', t.priority='normal',
|
|
370
|
+
t.createdAt=timestamp(), t.updatedAt=timestamp(),
|
|
371
|
+
t.createdByTool='remediate-glsmith-identity', t.createdBySession='task-1569', t.createdByAgent='id-remediation'
|
|
372
|
+
MERGE (t)-[:AFFECTS]->(keith)
|
|
373
|
+
MERGE (t)-[:RAISED_BY]->(dale)
|
|
374
|
+
RETURN t.taskId;
|
|
375
|
+
CYPHER
|
|
376
|
+
)"
|
|
377
|
+
log "step=keith-timeline taskCreated=1 taskId=$taskId raisedBy=owner-dale-person affects=keith-759"
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
step_account_admins() {
|
|
381
|
+
[ -f "$ACCOUNT_JSON" ] || { log "step=account-admins SKIP (account.json not found at $ACCOUNT_JSON)"; return; }
|
|
382
|
+
local already
|
|
383
|
+
already="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print("yes" if d.get("admins")==[] else "no")' "$ACCOUNT_JSON")"
|
|
384
|
+
if [ "$already" = "yes" ]; then log "step=account-admins no-op (already [])"; return; fi
|
|
385
|
+
if [ "$MODE" = "dry-run" ]; then
|
|
386
|
+
local cur; cur="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print([a.get("userId","")[:8] for a in d.get("admins",[])])' "$ACCOUNT_JSON")"
|
|
387
|
+
log "step=account-admins WOULD set admins [] (currently $cur)"; return
|
|
388
|
+
fi
|
|
389
|
+
local ts; ts="$(date +%Y%m%d-%H%M%S)"
|
|
390
|
+
cp "$ACCOUNT_JSON" "$ACCOUNT_JSON.bak-$ts"
|
|
391
|
+
python3 -c 'import json,sys
|
|
392
|
+
p=sys.argv[1]; d=json.load(open(p)); d["admins"]=[]
|
|
393
|
+
json.dump(d, open(p,"w"), indent=2)' "$ACCOUNT_JSON"
|
|
394
|
+
log "step=account-admins admins=[] backup=$ACCOUNT_JSON.bak-$ts"
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
step_reembed_needed() {
|
|
398
|
+
local keithTask
|
|
399
|
+
keithTask="$(run_cypher <<CYPHER | scalar
|
|
400
|
+
MATCH (t:Task {remediationKey:'1569-keith-bathroom-tap', accountId:'$ACCT'}) RETURN t.taskId;
|
|
401
|
+
CYPHER
|
|
402
|
+
)"
|
|
403
|
+
log "step=reembed-needed nodes=\"AdminUser:$OWNER(name-changed),Task:${keithTask:-<pending>}(new)\" note=raw-cypher-writes-carry-no-embedding"
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
# --- Rollback guard + snapshot (apply only) -------------------------
|
|
407
|
+
rollback_guard() {
|
|
408
|
+
if [ -n "$DUMP_PATH" ]; then
|
|
409
|
+
[ -s "$DUMP_PATH" ] || { echo "Error: --dump path '$DUMP_PATH' is missing or empty" >&2; exit 1; }
|
|
410
|
+
log "rollback dump provided path=$DUMP_PATH bytes=$(wc -c < "$DUMP_PATH")"
|
|
411
|
+
elif [ "$FORCE_NO_DUMP" -eq 1 ]; then
|
|
412
|
+
log "WARNING proceeding WITHOUT a neo4j dump (--force-no-dump); JSON snapshot is the only rollback reference"
|
|
413
|
+
else
|
|
414
|
+
echo "Error: --apply needs --dump=/path/to/neo4j.dump (fresh full dump) or --force-no-dump" >&2
|
|
415
|
+
echo " Take a dump on the box, e.g.:" >&2
|
|
416
|
+
echo " systemctl stop neo4j-sitedesk-code.service && \\" >&2
|
|
417
|
+
echo " neo4j-admin database dump neo4j --to-path=\$HOME/glsmith-1569-dump && \\" >&2
|
|
418
|
+
echo " systemctl start neo4j-sitedesk-code.service" >&2
|
|
419
|
+
exit 1
|
|
420
|
+
fi
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
write_snapshot() {
|
|
424
|
+
local ts snap
|
|
425
|
+
ts="$(date +%Y%m%d-%H%M%S)"; snap="$HOME/glsmith-1569-snapshot-$ts.json"
|
|
426
|
+
# Nodes: the whole sub-account plus the house-side nodes this script touches
|
|
427
|
+
# (house Person 324 via Joel's phone, house Dale Person 194 via Dale's phone,
|
|
428
|
+
# house operator 436fdf2d whose cross-account HAS_PROFILE is cleared).
|
|
429
|
+
run_cypher <<CYPHER > "$snap.nodes"
|
|
430
|
+
MATCH (n) WHERE n.accountId='$ACCT'
|
|
431
|
+
OR (n:Person AND n.telephone IN ['$DALE_PHONE','447504472444'])
|
|
432
|
+
OR (n:AdminUser AND n.userId IN ['$OWNER','$JOEL','$DALE','$HOUSEOP'])
|
|
433
|
+
RETURN elementId(n) AS eid, labels(n) AS labels, properties(n) AS props;
|
|
434
|
+
CYPHER
|
|
435
|
+
# Rels: every relationship anchored on a touched node, with its properties, so
|
|
436
|
+
# a re-pointed or dropped edge can be recreated exactly.
|
|
437
|
+
run_cypher <<CYPHER > "$snap.rels"
|
|
438
|
+
MATCH (n)-[r]-(m) WHERE n.accountId='$ACCT'
|
|
439
|
+
OR (n:AdminUser AND n.userId IN ['$OWNER','$JOEL','$DALE','$HOUSEOP'])
|
|
440
|
+
OR (n:Person AND n.telephone IN ['$DALE_PHONE','447504472444'])
|
|
441
|
+
RETURN DISTINCT type(r) AS type, elementId(startNode(r)) AS start, elementId(endNode(r)) AS end, properties(r) AS props;
|
|
442
|
+
CYPHER
|
|
443
|
+
log "snapshot nodes+rels written prefix=$snap"
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
# --- Verify ----------------------------------------------------------
|
|
447
|
+
verify() {
|
|
448
|
+
local fail=0
|
|
449
|
+
check() { # desc, actual, expected
|
|
450
|
+
if [ "$2" = "$3" ]; then log "verify OK $1 ($2)"; else log "verify FAIL $1 (got '$2' want '$3')"; fail=1; fi
|
|
451
|
+
}
|
|
452
|
+
local ownerCount ownerName ownerOwns ownerPrefs strayCount dalePersons joelCount daleCount gate
|
|
453
|
+
ownerCount="$(run_cypher <<< "MATCH (a:AdminUser {accountId:'$ACCT', role:'owner'}) RETURN count(a);" | as_int)"
|
|
454
|
+
ownerName="$(run_cypher <<< "MATCH (a:AdminUser {accountId:'$ACCT', role:'owner'}) RETURN a.name;" | scalar)"
|
|
455
|
+
ownerOwns="$(run_cypher <<< "MATCH (a:AdminUser {accountId:'$ACCT', role:'owner'})-[:OWNS]->(p:Person) RETURN count(p);" | as_int)"
|
|
456
|
+
ownerPrefs="$(run_cypher <<< "MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:HAS_PROFILE]->(op)-[:HAS_PREFERENCE]->(p) RETURN count(p);" | as_int)"
|
|
457
|
+
strayCount="$(run_cypher <<< "MATCH (u:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'}) RETURN count(u);" | as_int)"
|
|
458
|
+
dalePersons="$(run_cypher <<< "MATCH (p:Person {accountId:'$ACCT'}) WHERE p.givenName='Dale' AND p.familyName='Smith' RETURN count(p);" | as_int)"
|
|
459
|
+
joelCount="$(run_cypher <<< "MATCH (a:AdminUser {userId:'$JOEL', accountId:'$ACCT'}) RETURN count(a);" | as_int)"
|
|
460
|
+
daleCount="$(run_cypher <<< "MATCH (a:AdminUser {userId:'$DALE', accountId:'$ACCT'}) RETURN count(a);" | as_int)"
|
|
461
|
+
gate="$(write_gate || true)"
|
|
462
|
+
check "one role:owner AdminUser" "$ownerCount" "1"
|
|
463
|
+
check "owner named Dale Smith" "$ownerName" "Dale Smith"
|
|
464
|
+
check "owner owns exactly one Person" "$ownerOwns" "1"
|
|
465
|
+
check "owner profile pref count" "$ownerPrefs" "28"
|
|
466
|
+
check "stray profile removed" "$strayCount" "0"
|
|
467
|
+
check "one Dale Person on account" "$dalePersons" "1"
|
|
468
|
+
check "Joel seat absent" "$joelCount" "0"
|
|
469
|
+
check "Dale seat absent" "$daleCount" "0"
|
|
470
|
+
case "$gate" in *"owners=1 business=1"*) log "verify OK write-gate ($gate)";; *) log "verify FAIL write-gate ($gate)"; fail=1;; esac
|
|
471
|
+
[ "$fail" -eq 0 ] && { log "verify PASS"; return 0; } || { log "verify FAILED"; return 1; }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
# --- Main ------------------------------------------------------------
|
|
475
|
+
log "start mode=$MODE account=$ACCT uri=$URI"
|
|
476
|
+
|
|
477
|
+
if [ "$MODE" = "verify" ]; then verify; exit $?; fi
|
|
478
|
+
|
|
479
|
+
preconditions
|
|
480
|
+
|
|
481
|
+
if [ "$MODE" = "apply" ]; then
|
|
482
|
+
rollback_guard
|
|
483
|
+
write_snapshot
|
|
484
|
+
fi
|
|
485
|
+
|
|
486
|
+
step_rename_owner
|
|
487
|
+
step_rehome_prefs
|
|
488
|
+
step_merge_person
|
|
489
|
+
step_fold_dale_seat
|
|
490
|
+
step_retire_joel_seat
|
|
491
|
+
step_keith_timeline
|
|
492
|
+
step_account_admins
|
|
493
|
+
step_reembed_needed
|
|
494
|
+
|
|
495
|
+
if [ "$MODE" = "apply" ]; then
|
|
496
|
+
log "apply complete — running verify"
|
|
497
|
+
verify || { log "POST-APPLY VERIFY FAILED — inspect before trusting"; exit 4; }
|
|
498
|
+
else
|
|
499
|
+
log "dry-run complete — no writes performed. Diff the WOULD lines against the target end-state, then re-run with --apply --dump=<path>."
|
|
500
|
+
fi
|
|
@@ -220,30 +220,46 @@ fi
|
|
|
220
220
|
|
|
221
221
|
if [ -n "$USER_ID" ]; then
|
|
222
222
|
# Escape single quotes for Cypher string interpolation (e.g. O'Brien → O''Brien).
|
|
223
|
-
USER_NAME=$(python3 -c "import json; n=json.load(open('$USERS_FILE'))[0]['name']; print(n.replace(\"'\",\"''\"))" 2>/dev/null ||
|
|
224
|
-
|
|
223
|
+
USER_NAME=$(python3 -c "import json; n=json.load(open('$USERS_FILE'))[0]['name']; print(n.replace(\"'\",\"''\"))" 2>/dev/null || true)
|
|
224
|
+
# Trim surrounding whitespace so a padded value is neither persisted verbatim
|
|
225
|
+
# nor able to slip a padded " Owner " past the placeholder check below — the
|
|
226
|
+
# same trim the shared isPlaceholderOwnerName predicate applies.
|
|
227
|
+
USER_NAME=$(printf '%s' "$USER_NAME" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
|
228
|
+
# Task 1570 — the AdminUser carries no name; the owner name lives only on the
|
|
229
|
+
# OWNS-Person that set-pin's writeAdminUserAndPerson already wrote (1567 made a
|
|
230
|
+
# real name mandatory there). This block only stamps accountId/role and the
|
|
231
|
+
# ADMIN_OF edge on that existing AdminUser. A blank or "Owner" USER_NAME means
|
|
232
|
+
# the install cannot supply a real owner identity, so SKIP the stamp loudly
|
|
233
|
+
# rather than anchor an owner seat to a placeholder. Skip, not exit: this block
|
|
234
|
+
# only runs when users.json exists, so the real node is untouched, the per-deploy
|
|
235
|
+
# reconciles below still run (including the au.name -> Person backfill), and the
|
|
236
|
+
# standing [identity-audit] surfaces any orphan/nameless owner. Aborting the
|
|
237
|
+
# whole deploy here would brick re-deploys of exactly the installs to remediate.
|
|
238
|
+
if [ -z "$USER_NAME" ] || [ "$USER_NAME" = "Owner" ]; then
|
|
239
|
+
echo " [seed] WARN: owner name is blank or the placeholder \"Owner\" (users.json[0].name for userId=${USER_ID:0:8}) — skipping owner AdminUser seed. Set a real name (change-PIN) to reconcile; the standing [identity-audit] flags it meanwhile." >&2
|
|
240
|
+
else
|
|
241
|
+
CREATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
225
242
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
243
|
+
echo "==> Creating AdminUser node for userId=$USER_ID"
|
|
244
|
+
# Stamp accountId + role='owner' at seed so the graph-write gate's
|
|
245
|
+
# account-scoped MATCH (au:AdminUser {accountId: $accountId}) succeeds on the
|
|
246
|
+
# first user-domain write. The ON MATCH branch COALESCEs to preserve any
|
|
247
|
+
# existing non-null value.
|
|
248
|
+
"$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" << CYPHER_EOF
|
|
232
249
|
MERGE (au:AdminUser {userId: '$USER_ID'})
|
|
233
250
|
ON CREATE SET au.accountId = '$ACCOUNT_ID',
|
|
234
251
|
au.role = 'owner',
|
|
235
|
-
au.name = '$USER_NAME',
|
|
236
252
|
au.createdAt = '$CREATED_AT'
|
|
237
253
|
ON MATCH SET au.accountId = COALESCE(au.accountId, '$ACCOUNT_ID'),
|
|
238
|
-
au.role = COALESCE(au.role, 'owner')
|
|
239
|
-
au.name = '$USER_NAME'
|
|
254
|
+
au.role = COALESCE(au.role, 'owner')
|
|
240
255
|
WITH au
|
|
241
256
|
MATCH (b:LocalBusiness {accountId: '$ACCOUNT_ID'})
|
|
242
257
|
MERGE (au)-[r:ADMIN_OF]->(b)
|
|
243
258
|
ON CREATE SET r.role = 'owner', r.grantedAt = '$CREATED_AT'
|
|
244
259
|
RETURN au.userId, b.accountId;
|
|
245
260
|
CYPHER_EOF
|
|
246
|
-
|
|
261
|
+
echo " [seed] AdminUser stamped userId=${USER_ID:0:8} accountId=${ACCOUNT_ID:0:8} role=owner"
|
|
262
|
+
fi
|
|
247
263
|
else
|
|
248
264
|
echo " No users.json found — skipping AdminUser seed (set-pin POST creates it)"
|
|
249
265
|
fi
|
|
@@ -304,4 +320,53 @@ PROG_TOTAL=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_UR
|
|
|
304
320
|
"MATCH (s:Sale {accountId: '$ACCOUNT_ID'}) RETURN count(s)" 2>/dev/null | tail -1 | tr -d ' \r')
|
|
305
321
|
echo " [progression-reconcile] accountId=${ACCOUNT_ID:0:8} stalledSales=${PROG_STALLED:-0} orphanChains=${PROG_ORPHAN_CHAINS:-0} linklessChains=${PROG_LINKLESS:-0} total=${PROG_TOTAL:-0}"
|
|
306
322
|
|
|
323
|
+
# ------------------------------------------------------------------
|
|
324
|
+
# 7. One-time reconcile — AdminUser.name -> owned Person, then drop au.name (Task 1570)
|
|
325
|
+
# ------------------------------------------------------------------
|
|
326
|
+
# AdminUser carries no name; the owned Person is the sole name source. This heals
|
|
327
|
+
# legacy nodes globally (every account in the shared graph, not just this one).
|
|
328
|
+
# It must NOT touch an AdminUser that already has a named owned Person (the common
|
|
329
|
+
# case: a house owner with a real "Joel Smalley" Person) — only 7c drops au.name
|
|
330
|
+
# there. So the migrate is split so it never MERGE-duplicates a name-keyed Person:
|
|
331
|
+
# 7a — an owned Person exists but is nameless: populate its name.
|
|
332
|
+
# 7b — no owned Person exists at all: create one carrying the name.
|
|
333
|
+
# Owner/admin-personal Persons are keyed on {accountId} + name (writeAdminUserAndPerson,
|
|
334
|
+
# admin-add) or {accountId, userId} (seedAccountGraphRoot); keying a MERGE on
|
|
335
|
+
# {accountId, userId} would miss the former and create a duplicate, so 7a SETs the
|
|
336
|
+
# existing node and only 7b CREATEs (for a genuine orphan). Placeholder names
|
|
337
|
+
# ("Owner"/"Admin"/blank) are excluded from 7a/7b and simply dropped by 7c.
|
|
338
|
+
# Idempotent: once au.name is gone all three match nothing, so re-deploys are
|
|
339
|
+
# no-ops. Split mirrors writeAdminUserAndPerson (first ASCII space: head ->
|
|
340
|
+
# givenName, tail -> familyName); operator names never carry embedded tab/NBSP.
|
|
341
|
+
# Visibility only — never install-blocking.
|
|
342
|
+
IDENT_NAMED=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
|
|
343
|
+
--format plain \
|
|
344
|
+
"MATCH (au:AdminUser)-[:OWNS]->(p:Person)
|
|
345
|
+
WHERE p.accountId = au.accountId
|
|
346
|
+
AND (p.givenName IS NULL OR trim(p.givenName) = '')
|
|
347
|
+
AND au.name IS NOT NULL
|
|
348
|
+
AND trim(au.name) <> '' AND trim(au.name) <> 'Owner' AND trim(au.name) <> 'Admin'
|
|
349
|
+
WITH p, trim(au.name) AS nm
|
|
350
|
+
SET p.givenName = CASE WHEN nm CONTAINS ' ' THEN trim(split(nm, ' ')[0]) ELSE nm END,
|
|
351
|
+
p.familyName = CASE WHEN nm CONTAINS ' ' THEN trim(substring(nm, size(split(nm, ' ')[0]) + 1)) ELSE null END
|
|
352
|
+
RETURN count(p)" 2>/dev/null | tail -1 | tr -d ' \r')
|
|
353
|
+
IDENT_CREATED=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
|
|
354
|
+
--format plain \
|
|
355
|
+
"MATCH (au:AdminUser)
|
|
356
|
+
WHERE au.name IS NOT NULL
|
|
357
|
+
AND trim(au.name) <> '' AND trim(au.name) <> 'Owner' AND trim(au.name) <> 'Admin'
|
|
358
|
+
AND au.accountId IS NOT NULL
|
|
359
|
+
AND NOT EXISTS { MATCH (au)-[:OWNS]->(p:Person) WHERE p.accountId = au.accountId }
|
|
360
|
+
WITH au, trim(au.name) AS nm
|
|
361
|
+
CREATE (au)-[:OWNS]->(np:Person {
|
|
362
|
+
accountId: au.accountId, userId: au.userId, role: 'admin-personal', scope: 'admin', createdAt: datetime(),
|
|
363
|
+
givenName: CASE WHEN nm CONTAINS ' ' THEN trim(split(nm, ' ')[0]) ELSE nm END,
|
|
364
|
+
familyName: CASE WHEN nm CONTAINS ' ' THEN trim(substring(nm, size(split(nm, ' ')[0]) + 1)) ELSE null END
|
|
365
|
+
})
|
|
366
|
+
RETURN count(np)" 2>/dev/null | tail -1 | tr -d ' \r')
|
|
367
|
+
IDENT_DROPPED=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
|
|
368
|
+
--format plain \
|
|
369
|
+
"MATCH (au:AdminUser) WHERE au.name IS NOT NULL REMOVE au.name RETURN count(au)" 2>/dev/null | tail -1 | tr -d ' \r')
|
|
370
|
+
echo " [identity-reconcile] namedExisting=${IDENT_NAMED:-0} createdOwned=${IDENT_CREATED:-0} droppedAuName=${IDENT_DROPPED:-0}"
|
|
371
|
+
|
|
307
372
|
echo " Done."
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
|