Mobile P3b: investor name + contact-pill editing (update-row)
Adds the editable half of BRIEF §3a's mobile grid set: rename an investor and add/edit/remove its contact pills from the mobile detail sheet. New POST /api/fundraising/update-row is the one-row read-fresh-modify-write twin of log-communication: it mutates only the target row's name/contacts in the canonical grid blob server-side, then bumps the version + re-syncs the relational tables. It never accepts a whole-grid payload, so a stale mobile client can't clobber concurrent edits to other rows (the reason mobile avoids the whole-grid PUT). _sanitize_fundraising_contacts whitelists the known pill fields as the trust boundary; removing a pill is soft on the classic contacts directory (only the grid pill + fundraising_contacts row drop). Frontend: MobileFundraisingGrid gains an Edit bottom-sheet (name input + pill editor with client-side dedup); money stays desktop-only. New CSS is theme-var-only so it flips in light mode. Verified: test_fundraising_update_row.py (24 assertions, real HTTP), full suite 37/37, render-smoke + a 375px jsdom interaction harness green.
This commit is contained in:
@@ -107,27 +107,12 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
_**Box live at v0.1.0:94**; `main` (pushed through `e6a8945`) ahead by mobile Phases 0–6 + drag-reorder views — **all deploy-pending** (no s9pk built yet). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign** — **all 4 surfaces + the light theme done (P0–P6)**. **Plan (Grant, 2026-06-19): finish features first — P3b name/pill edit → Phase 7 design-conformance pass, in that order next session — then Grant does device testing + deploy** (NOT before; everything is unverified on a real phone). Per-phase detail + backlog: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._
|
_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 0–6 + P3b name/pill edit + drag-reorder views — **all deploy-pending** (no s9pk built yet). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign** — **all 4 surfaces + light theme + P3b (name/pill edit) done; BRIEF §3a editable set complete**. **Plan (Grant, 2026-06-19): finish features first — P3b done → Phase 7 design-conformance pass next — then Grant does device testing + deploy** (NOT before; everything is unverified on a real phone). Per-phase detail + backlog: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._
|
||||||
|
|
||||||
- **Mobile redesign — all 4 core surfaces built + committed (Grid · Contacts · Pipeline · Reminders).** Each is a rules-of-hooks-safe `useIsMobile()` wrapper → `Mobile*`/`Desktop*` pair (**desktop untouched**), re-authored against the real API on shared primitives `<BottomSheet>`/`useIsMobile()`/`StageChip`/`MobileDetailRow`. Foundation: bottom-tab bar + `:root` mobile vars (P1); 4-stage enum + read-only derived grid signals (`existing_investor`/`last_activity_at`/`staleness`/`opportunity_id`) injected on GET, **stripped on write at both points** (P0/P3a `_computed_row_values` + `stripComputedRows`). **Mobile writes use one-row endpoints only — never whole-grid PUT** (BRIEF §3a). Per-phase detail in `ROADMAP.md`.
|
- **Mobile redesign — all 4 core surfaces built + committed (Grid · Contacts · Pipeline · Reminders).** Each is a rules-of-hooks-safe `useIsMobile()` wrapper → `Mobile*`/`Desktop*` pair (**desktop untouched**), re-authored against the real API on shared primitives `<BottomSheet>`/`useIsMobile()`/`StageChip`/`MobileDetailRow`. Foundation: bottom-tab bar + `:root` mobile vars (P1); 4-stage enum + read-only derived grid signals (`existing_investor`/`last_activity_at`/`staleness`/`opportunity_id`) injected on GET, **stripped on write at both points** (P0/P3a `_computed_row_values` + `stripComputedRows`). **Mobile writes use one-row endpoints only — never whole-grid PUT** (BRIEF §3a): log-communication, pipeline link/stage, reminders, and now **`POST /api/fundraising/update-row`** (P3b name/pill edit). Per-phase detail in `ROADMAP.md`.
|
||||||
- **This session — P6 light theme (committed `e6a8945`, frontend-only).** App-wide light theme behind
|
- **This session — P3b investor name + contact-pill edit (frontend + backend; not yet committed at write time → see git log).** New **version-safe `POST /api/fundraising/update-row`** (`handle_update_fundraising_row`) — the one-row read-fresh-modify-write twin of `handle_log_fundraising_communication`: reads the canonical grid blob server-side, mutates ONLY the target row's `investor_name`/`contacts`, writes back with a version bump + `sync_fundraising_relational`. Never a whole-grid payload → can't clobber concurrent edits to other rows (BRIEF §3a). Server-side `_sanitize_fundraising_contacts` (9-field whitelist; drops name+email-empty pills) is the trust boundary. Frontend: `MobileFundraisingGrid` detail gains an **Edit** bottom-sheet (investor-name input + pill add/edit/remove of name·email·title, preserving each pill's location/LinkedIn fields, **client-side dedup** by email→name) → saves through update-row → reloads; money stays desktop-only. Pill removal is **soft** on the classic `contacts` directory (only the grid pill + `fundraising_contacts` row drop). New CSS is theme-var-only (`.pill-edit*`/`.sheet-addbtn`/`.fs-detail-edit`). Verified: **`backend/test_fundraising_update_row.py`** (24 assertions over real HTTP — rename/add/edit/remove, other-row-untouched, relational + classic-contacts sync, soft-remove, name-only-pill kept, 5 guards); full suite **37/37**; render-smoke green; throwaway jsdom-375px harness drove the real edit flow (14/14: open→edit→rename→remove→add→save, asserted `POST update-row` + **no whole-grid PUT** + preserved fields). Reviewer-passed (nits were deliberate sibling-pattern parity). **No real-phone check.**
|
||||||
`:root[data-theme="light"]`; **dark stays default** (pre-paint boot script in `<head>` reads
|
- **Prior session (`e6a8945`) — P6 light theme (frontend-only).** App-wide light theme behind `:root[data-theme="light"]` (dark default; pre-paint boot script reads `localStorage.venture_crm_theme`); toggle in desktop sidebar footer + mobile top bar off one `App` `theme` state. 44 themed `:root` slots (dark == originals byte-for-byte), 319 hex→`var()` migrated, `StageChip` className-based. **Mobile light complete; desktop-light rough edges** (bespoke `<style>` shades: login glow/scrollbar/table-hover/KPI green, legacy off-palette `.badge-*`, dark shadows) → **Phase 7**. Verified render-smoke + jsdom toggle harness (7/7). **No real-phone check.** Earlier (`ee9db64`): **P4 Pipeline** + **P5 Reminders** (swipe-done/snooze) — detail in `ROADMAP.md`.
|
||||||
`localStorage.venture_crm_theme`; no `prefers-color-scheme`). **App-wide toggle:** labeled control
|
|
||||||
in the desktop sidebar footer + sun/moon icon in the mobile top bar, both off one `theme` state in
|
|
||||||
`App`. Color pairs taken from the **full Claude Design export** (`design/_imports/2026-06-19_zip-file/`,
|
|
||||||
with the previously-missing `store.js` + `*App.dc.html` `DCLogic` palettes) — exact dark+light for
|
|
||||||
every stage/recency/note/priority/reminder/money tint. **Method = zero dark regression by
|
|
||||||
construction:** grew `:root` to 44 themed slots whose **dark values == the originals byte-for-byte**
|
|
||||||
(verified), then migrated **319 hex→`var()`** (script for structural; targeted edits for semantic/
|
|
||||||
chip helpers — `StageChip` now className-based, `PIPELINE_STAGE_CHIP` removed). Mobile surfaces +
|
|
||||||
chrome are fully var-based → **mobile light complete**. **Desktop light rough edges** (bespoke
|
|
||||||
`<style>` shades: login glow/scrollbar/table-hover/KPI green, the legacy off-palette `.badge-*`
|
|
||||||
family, dark-tuned shadows) deferred to the new **Phase 7 conformance pass** (per Grant: conform
|
|
||||||
*everything* — buttons/colors/functionality — to the Claude Design export). Verified: render-smoke
|
|
||||||
green; jsdom interaction harness on the **authed shell** (boot-dark → toggle→light+persist+relabel →
|
|
||||||
toggle→dark, 7/7); var parity + dark-identity + no-undefined-var checks green. **No real-phone check.**
|
|
||||||
- **Prior session (`ee9db64`, no backend change):** **P4 Pipeline** (`MobilePipeline` = CSS scroll-snap swipe-between-stages + segmented control + dots, per-card ‹/› move + tap→detail w/ stage sheet; shares `PATCH /api/opportunities/{id}/stage`; view+advance-only) + **P5 Reminders** (`MobileReminders` = urgency-grouped list over `/api/reminders` + Active/Done/All filter; `ReminderRow` pointer-drag **swipe-left=done / swipe-right=snooze +7d**; tap→create/edit sheet). Verified: render-smoke + jsdom-375px harness (12/12 each); reviewer-passed (notably **P5 `pointercancel` no longer fires a spurious mark-done**). Reusable swipe-test technique noted in memory.
|
|
||||||
- **Live (deployed):** W2 NL query (v94; remaining: in-room smoke + web "Ask" box); W1 reminders (v93); grid Pipeline (v88); Matrix intake + Gmail capture (DWD) + daily digest; Thesis/Architect (dual-approval); outreach — all draft-only.
|
- **Live (deployed):** W2 NL query (v94; remaining: in-room smoke + web "Ask" box); W1 reminders (v93); grid Pipeline (v88); Matrix intake + Gmail capture (DWD) + daily digest; Thesis/Architect (dual-approval); outreach — all draft-only.
|
||||||
- **Tests:** **36/36 backend green** (`python3 backend/run_tests.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean.
|
- **Tests:** **37/37 backend green** (`python3 backend/run_tests.py`; +`test_fundraising_update_row.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean.
|
||||||
- **Next (next session = finish features, in order):** 1) **P3b** name/pill edit — new version-safe `POST /api/fundraising/update-row` (single-row name/contacts; **never** whole-grid PUT) + a bottom-sheet pill editor (add/edit/remove, client-side dedup) + test; completes BRIEF §3a's editable set. 2) **Phase 7 design-conformance pass** against the full Claude Design export (`design/_imports/2026-06-19_zip-file/`) — buttons/colors/spacing/functionality across all surfaces, incl. finishing the P6 desktop-light rough edges (run `design-checker`). **Then (Grant, after feature-complete):** deploy P0–P7 + view-reorder in one s9pk (**authorize + version-bump first**) and device-test light/dark on a real phone. Later backlog: W2 web Ask box + smoke; W3 bot grid-mutations; W1b nurture-gap.
|
- **Next (next session = finish features):** 1) **Phase 7 design-conformance pass** against the full Claude Design export (`design/_imports/2026-06-19_zip-file/`) — buttons/colors/spacing/functionality across all surfaces, incl. finishing the P6 desktop-light rough edges (run `design-checker`). **Then (Grant, after feature-complete):** deploy P0–P6 + P3b + view-reorder in one s9pk (**authorize + version-bump first**) and device-test light/dark on a real phone. Later backlog: W2 web Ask box + smoke; W3 bot grid-mutations; W1b nurture-gap.
|
||||||
- **Open / risks:** all mobile work + **P6 light theme** **built but never deployed or tested on a real phone/browser** (render-smoke + jsdom only — verify on a device, both themes); **P6 desktop-light has known rough edges** (bespoke `<style>` shades, legacy `.badge-*` family, dark shadows → Phase 7); **P3b deferred**; W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical** (needs dual sign-off); doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
|
- **Open / risks:** all mobile work + **P6 light theme + P3b edit** **built but never deployed or tested on a real phone/browser** (render-smoke + jsdom only — verify on a device, both themes); **P6 desktop-light has known rough edges** (bespoke `<style>` shades, legacy `.badge-*` family, dark shadows → Phase 7); W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical** (needs dual sign-off); doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
|
||||||
|
|||||||
@@ -1621,6 +1621,32 @@ def sanitize_fundraising_grid(grid):
|
|||||||
|
|
||||||
return {"columns": clean_columns, "rows": clean_rows}
|
return {"columns": clean_columns, "rows": clean_rows}
|
||||||
|
|
||||||
|
# The full set of fields a fundraising contact "pill" carries (what the desktop contacts-cell
|
||||||
|
# editor produces). The mobile pill editor edits name/email/title and carries the rest through
|
||||||
|
# untouched; this whitelist sanitizes an inbound contacts array on the single-row update path.
|
||||||
|
_FUNDRAISING_CONTACT_FIELDS = ('name', 'email', 'title', 'source', 'city', 'state',
|
||||||
|
'country', 'location_query', 'linkedin_url')
|
||||||
|
|
||||||
|
def _sanitize_fundraising_contacts(raw):
|
||||||
|
"""Coerce a client-supplied contacts array to clean pill dicts: keep only the known pill
|
||||||
|
fields, trim strings, and drop entries with neither a name nor an email (the same emptiness
|
||||||
|
rule sync_fundraising_relational uses, so a sanitized list round-trips through sync without
|
||||||
|
surprise drops). Dedup is the client's job (BRIEF §3a); this only guards blanks + stray keys."""
|
||||||
|
out = []
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return out
|
||||||
|
for c in raw:
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
continue
|
||||||
|
pill = {}
|
||||||
|
for k in _FUNDRAISING_CONTACT_FIELDS:
|
||||||
|
if k in c and c.get(k) is not None:
|
||||||
|
pill[k] = str(c.get(k)).strip()
|
||||||
|
if not pill.get('name') and not pill.get('email'):
|
||||||
|
continue
|
||||||
|
out.append(pill)
|
||||||
|
return out
|
||||||
|
|
||||||
# ─── Grid ↔ Pipeline link (Adopt the Pipeline) ────────────────────────────────
|
# ─── Grid ↔ Pipeline link (Adopt the Pipeline) ────────────────────────────────
|
||||||
# The fundraising grid is canonical; the Pipeline board is a view of the deals it
|
# The fundraising grid is canonical; the Pipeline board is a view of the deals it
|
||||||
# drives. opportunities.fundraising_investor_id is the durable join. Two ownership
|
# drives. opportunities.fundraising_investor_id is the durable join. Two ownership
|
||||||
@@ -2355,6 +2381,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
return self.handle_create_feature_request(user, body)
|
return self.handle_create_feature_request(user, body)
|
||||||
if path == '/api/fundraising/log-communication':
|
if path == '/api/fundraising/log-communication':
|
||||||
return self.handle_log_fundraising_communication(user, body)
|
return self.handle_log_fundraising_communication(user, body)
|
||||||
|
if path == '/api/fundraising/update-row':
|
||||||
|
return self.handle_update_fundraising_row(user, body)
|
||||||
if path == '/api/fundraising/pipeline/link':
|
if path == '/api/fundraising/pipeline/link':
|
||||||
return self.handle_pipeline_link(user, body)
|
return self.handle_pipeline_link(user, body)
|
||||||
if path == '/api/fundraising/pipeline/unlink':
|
if path == '/api/fundraising/pipeline/unlink':
|
||||||
@@ -3381,6 +3409,96 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
conn.close()
|
conn.close()
|
||||||
return self.send_json({"data": {"communication": comm, "row": target_row, "version": next_version}}, 201)
|
return self.send_json({"data": {"communication": comm, "row": target_row, "version": next_version}}, 201)
|
||||||
|
|
||||||
|
def handle_update_fundraising_row(self, user, body):
|
||||||
|
"""Version-safe single-row edit of an investor's NAME and/or contact pills (P3b).
|
||||||
|
|
||||||
|
The one-row twin of handle_log_fundraising_communication: the canonical grid blob is read
|
||||||
|
FRESH server-side, ONLY the target row's investor_name / contacts are mutated, then the
|
||||||
|
blob is written back with a version bump + relational re-sync. It never accepts a
|
||||||
|
whole-grid payload, so a stale mobile client can't clobber concurrent edits to OTHER rows
|
||||||
|
the way the grid PUT (handle_update_fundraising_state) would (BRIEF §3a). Same-row
|
||||||
|
name/contacts is last-write-wins, exactly like the notes append in log-communication.
|
||||||
|
|
||||||
|
Body: { row_id (required), investor_name?, contacts? } — at least one of investor_name /
|
||||||
|
contacts must be present. `contacts` REPLACES the row's full pill list (the client owns
|
||||||
|
add/edit/remove + dedup). Removing a pill drops it from the row + its fundraising_contacts
|
||||||
|
row on re-sync, but never hard-deletes the classic contacts directory entry
|
||||||
|
(soft-delete-only convention — _upsert_contact_from_fundraising only ever upserts)."""
|
||||||
|
row_id = str(body.get('row_id') or '').strip()
|
||||||
|
if not row_id:
|
||||||
|
return self.send_error_json("row_id is required")
|
||||||
|
|
||||||
|
has_name = 'investor_name' in body
|
||||||
|
has_contacts = 'contacts' in body
|
||||||
|
if not has_name and not has_contacts:
|
||||||
|
return self.send_error_json("investor_name or contacts is required")
|
||||||
|
|
||||||
|
new_name = None
|
||||||
|
if has_name:
|
||||||
|
new_name = str(body.get('investor_name') or '').strip()
|
||||||
|
if not new_name:
|
||||||
|
return self.send_error_json("investor_name cannot be blank")
|
||||||
|
|
||||||
|
new_contacts = None
|
||||||
|
if has_contacts:
|
||||||
|
if not isinstance(body.get('contacts'), list):
|
||||||
|
return self.send_error_json("contacts must be an array")
|
||||||
|
new_contacts = _sanitize_fundraising_contacts(body.get('contacts'))
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
ensure_fundraising_state_row(conn)
|
||||||
|
state = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
|
||||||
|
if not state:
|
||||||
|
conn.close()
|
||||||
|
return self.send_error_json("Fundraising state not found", 404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
grid = json.loads(state['grid_json']) if state['grid_json'] else {}
|
||||||
|
except Exception:
|
||||||
|
grid = {}
|
||||||
|
grid = sanitize_fundraising_grid(grid)
|
||||||
|
rows = grid.get('rows', [])
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
target_index = -1
|
||||||
|
target_row = None
|
||||||
|
for idx, row in enumerate(rows):
|
||||||
|
if isinstance(row, dict) and str(row.get('id') or '').strip() == row_id:
|
||||||
|
target_index = idx
|
||||||
|
target_row = row
|
||||||
|
break
|
||||||
|
if target_row is None:
|
||||||
|
conn.close()
|
||||||
|
return self.send_error_json("Fundraising row not found", 404)
|
||||||
|
|
||||||
|
if new_name is not None:
|
||||||
|
target_row['investor_name'] = new_name
|
||||||
|
if new_contacts is not None:
|
||||||
|
target_row['contacts'] = new_contacts
|
||||||
|
rows[target_index] = target_row
|
||||||
|
|
||||||
|
try:
|
||||||
|
views = json.loads(state['views_json']) if state['views_json'] else []
|
||||||
|
except Exception:
|
||||||
|
views = []
|
||||||
|
views = sanitize_grid_views(views)
|
||||||
|
next_version = int(state['version'] or 1) + 1
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE fundraising_state
|
||||||
|
SET grid_json = ?, views_json = ?, version = ?, updated_by = ?, updated_at = ?
|
||||||
|
WHERE id = 'main'
|
||||||
|
""", (json.dumps(grid), json.dumps(views), next_version, user['user_id'], now()))
|
||||||
|
sync_fundraising_relational(conn, grid, views, actor_user_id=user['user_id'])
|
||||||
|
log_audit(conn, user['user_id'], 'fundraising_state', 'main', 'update_row',
|
||||||
|
{"version": next_version, "row_id": row_id})
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# The returned row lacks the read-only computed columns (pipeline/existing_investor/…),
|
||||||
|
# which are injected only on the GET path — the mobile client reloads after a save.
|
||||||
|
return self.send_json({"data": {"row": target_row, "version": next_version}}, 200)
|
||||||
|
|
||||||
def _fetch_opportunity_row(self, conn, opp_id):
|
def _fetch_opportunity_row(self, conn, opp_id):
|
||||||
return row_to_dict(conn.execute("""
|
return row_to_dict(conn.execute("""
|
||||||
SELECT op.*, c.first_name, c.last_name, c.email as contact_email,
|
SELECT op.*, c.first_name, c.last_name, c.email as contact_email,
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Integration test for the version-safe single-row update endpoint (P3b).
|
||||||
|
|
||||||
|
POST /api/fundraising/update-row edits ONE investor row's name and/or contact pills by
|
||||||
|
reading the canonical grid blob fresh server-side and mutating only the target row — never
|
||||||
|
accepting a whole-grid payload (BRIEF §3a), so it can't clobber concurrent edits to other
|
||||||
|
rows. This boots the REAL server against a throwaway DB, seeds a two-row grid, then drives
|
||||||
|
the live HTTP endpoint to assert:
|
||||||
|
* rename + pill add/edit persist into the blob and bump the version;
|
||||||
|
* removing a pill drops it from the row + fundraising_contacts, but the classic contacts
|
||||||
|
directory entry is NOT hard-deleted (soft-delete-only convention);
|
||||||
|
* preserved pill fields (title/city/linkedin) survive an edit that only touches the name;
|
||||||
|
* the OTHER grid row is untouched (no whole-grid clobber);
|
||||||
|
* guards: missing row_id -> 400, unknown row_id -> 404, blank name -> 400,
|
||||||
|
no-op body (neither field) -> 400.
|
||||||
|
Synthetic data only (guardrail #9).
|
||||||
|
|
||||||
|
Run: cd backend && python3 test_fundraising_update_row.py
|
||||||
|
"""
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
_DATA = tempfile.mkdtemp()
|
||||||
|
os.environ["CRM_DATA_DIR"] = _DATA
|
||||||
|
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import server # noqa: E402
|
||||||
|
|
||||||
|
FAILS = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
print((" PASS " if cond else " FAIL ") + msg)
|
||||||
|
if not cond:
|
||||||
|
FAILS.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class _Quiet(server.CRMHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _post(port, path, token, payload):
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||||
|
conn.request("POST", path, body=json.dumps(payload),
|
||||||
|
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"})
|
||||||
|
resp = conn.getresponse()
|
||||||
|
raw = resp.read().decode("utf-8", "replace")
|
||||||
|
conn.close()
|
||||||
|
data = json.loads(raw) if raw else None
|
||||||
|
return resp.status, data
|
||||||
|
|
||||||
|
|
||||||
|
GRID = {
|
||||||
|
"columns": [
|
||||||
|
{"id": "investor_name", "label": "Investor Name", "type": "text"},
|
||||||
|
{"id": "contacts", "label": "Contacts", "type": "contacts"},
|
||||||
|
{"id": "notes", "label": "Notes", "type": "text"},
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
{"id": "row-1", "investor_name": "Acme Capital", "notes": "",
|
||||||
|
"contacts": [
|
||||||
|
{"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner",
|
||||||
|
"city": "Austin", "state": "TX", "country": "USA", "location_query": "Austin",
|
||||||
|
"linkedin_url": "https://linkedin.com/in/janedoe"},
|
||||||
|
{"name": "Bob Roe", "email": "bob@acme.com", "title": ""},
|
||||||
|
]},
|
||||||
|
{"id": "row-2", "investor_name": "Beacon Fund", "notes": "untouched",
|
||||||
|
"contacts": [{"name": "Carl Vane", "email": "carl@beacon.com"}]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def seed():
|
||||||
|
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
||||||
|
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
||||||
|
c.execute("INSERT INTO fundraising_state (id, grid_json, views_json, version, updated_by) "
|
||||||
|
"VALUES ('main', ?, '[]', 1, 'u1') "
|
||||||
|
"ON CONFLICT(id) DO UPDATE SET grid_json=excluded.grid_json, views_json='[]', version=1",
|
||||||
|
(json.dumps(GRID),))
|
||||||
|
server.sync_fundraising_relational(c, server.sanitize_fundraising_grid(GRID), [], actor_user_id="u1")
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def blob_rows():
|
||||||
|
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
row = c.execute("SELECT grid_json, version FROM fundraising_state WHERE id='main'").fetchone()
|
||||||
|
c.close()
|
||||||
|
grid = json.loads(row["grid_json"])
|
||||||
|
by_id = {r["id"]: r for r in grid["rows"]}
|
||||||
|
return by_id, int(row["version"])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
server.init_db()
|
||||||
|
seed()
|
||||||
|
token = server.create_token("u1", "grant", "admin")
|
||||||
|
|
||||||
|
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
|
||||||
|
port = httpd.server_address[1]
|
||||||
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||||
|
try:
|
||||||
|
# ── rename + add a third contact, edit Bob's title, keep Jane unchanged ──
|
||||||
|
print("\n[rename + pill add/edit]")
|
||||||
|
new_contacts = [
|
||||||
|
# Jane: only her name is "re-sent"; the client preserves her other fields by spread.
|
||||||
|
{"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner",
|
||||||
|
"city": "Austin", "state": "TX", "country": "USA", "location_query": "Austin",
|
||||||
|
"linkedin_url": "https://linkedin.com/in/janedoe"},
|
||||||
|
{"name": "Bob Roe", "email": "bob@acme.com", "title": "Principal"}, # edited title
|
||||||
|
{"name": "Dana Fox", "email": "dana@acme.com", "title": "Analyst"}, # added
|
||||||
|
]
|
||||||
|
st, data = _post(port, "/api/fundraising/update-row", token,
|
||||||
|
{"row_id": "row-1", "investor_name": "Acme Capital LLC", "contacts": new_contacts})
|
||||||
|
check(st == 200, f"update-row -> 200 (got {st})")
|
||||||
|
by_id, version = blob_rows()
|
||||||
|
check(version == 2, f"version bumped 1 -> 2 (got {version})")
|
||||||
|
r1 = by_id.get("row-1", {})
|
||||||
|
check(r1.get("investor_name") == "Acme Capital LLC", f"name renamed in blob (got {r1.get('investor_name')!r})")
|
||||||
|
names = [c.get("name") for c in r1.get("contacts", [])]
|
||||||
|
check(names == ["Jane Doe", "Bob Roe", "Dana Fox"], f"three pills in order (got {names})")
|
||||||
|
bob = next((c for c in r1["contacts"] if c["name"] == "Bob Roe"), {})
|
||||||
|
check(bob.get("title") == "Principal", f"Bob's title edited (got {bob.get('title')!r})")
|
||||||
|
jane = next((c for c in r1["contacts"] if c["name"] == "Jane Doe"), {})
|
||||||
|
check(jane.get("linkedin_url") == "https://linkedin.com/in/janedoe" and jane.get("city") == "Austin",
|
||||||
|
f"Jane's preserved fields survived (got {jane})")
|
||||||
|
|
||||||
|
# ── the OTHER row is byte-for-byte untouched (no whole-grid clobber) ──
|
||||||
|
print("\n[other row untouched]")
|
||||||
|
r2 = by_id.get("row-2", {})
|
||||||
|
check(r2.get("investor_name") == "Beacon Fund" and r2.get("notes") == "untouched"
|
||||||
|
and [c.get("name") for c in r2.get("contacts", [])] == ["Carl Vane"],
|
||||||
|
f"row-2 unchanged (got {r2})")
|
||||||
|
|
||||||
|
# ── relational sync: classic contacts directory now has Dana; Dana also in fundraising_contacts ──
|
||||||
|
print("\n[relational + classic-contacts sync]")
|
||||||
|
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
dana = c.execute("SELECT id, deleted_at FROM contacts WHERE lower(email)='dana@acme.com'").fetchone()
|
||||||
|
check(bool(dana), "added contact Dana propagated to classic contacts directory")
|
||||||
|
inv = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id='row-1'").fetchone()
|
||||||
|
fc_names = {r["full_name"] for r in c.execute(
|
||||||
|
"SELECT full_name FROM fundraising_contacts WHERE investor_id=?", (inv["id"],)).fetchall()}
|
||||||
|
check(fc_names == {"Jane Doe", "Bob Roe", "Dana Fox"}, f"fundraising_contacts mirrors the three pills (got {fc_names})")
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
# ── remove Bob: pill drops + fundraising_contacts drops, but classic contact NOT hard-deleted ──
|
||||||
|
print("\n[remove pill is soft on the classic directory]")
|
||||||
|
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": [
|
||||||
|
{"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner"},
|
||||||
|
{"name": "Dana Fox", "email": "dana@acme.com", "title": "Analyst"},
|
||||||
|
]})
|
||||||
|
check(st == 200, f"remove-pill update -> 200 (got {st})")
|
||||||
|
by_id, version = blob_rows()
|
||||||
|
names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
|
||||||
|
check(names == ["Jane Doe", "Dana Fox"], f"Bob removed from the row (got {names})")
|
||||||
|
check(version == 3, f"version bumped 2 -> 3 (got {version})")
|
||||||
|
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
inv = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id='row-1'").fetchone()
|
||||||
|
fc_names = {r["full_name"] for r in c.execute(
|
||||||
|
"SELECT full_name FROM fundraising_contacts WHERE investor_id=?", (inv["id"],)).fetchall()}
|
||||||
|
check("Bob Roe" not in fc_names, f"Bob dropped from fundraising_contacts (got {fc_names})")
|
||||||
|
bob_classic = c.execute("SELECT id FROM contacts WHERE lower(email)='bob@acme.com'").fetchone()
|
||||||
|
check(bool(bob_classic), "removing a pill does NOT hard-delete the classic contacts row (soft-delete only)")
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
# ── name-only update (no contacts key) leaves the pill list intact ──
|
||||||
|
print("\n[name-only update preserves contacts]")
|
||||||
|
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "investor_name": "Acme Capital"})
|
||||||
|
check(st == 200, f"name-only update -> 200 (got {st})")
|
||||||
|
by_id, _ = blob_rows()
|
||||||
|
names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
|
||||||
|
check(by_id["row-1"].get("investor_name") == "Acme Capital", "name updated again")
|
||||||
|
check(names == ["Jane Doe", "Dana Fox"], f"contacts untouched by a name-only edit (got {names})")
|
||||||
|
|
||||||
|
# ── a name-only pill (no email) is KEPT; a fully-blank pill is dropped ──
|
||||||
|
# (locks _sanitize_fundraising_contacts's emptiness rule = name OR email, not AND.)
|
||||||
|
print("\n[name-only pill kept, blank pill dropped]")
|
||||||
|
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": [
|
||||||
|
{"name": "Jane Doe", "email": "jane@acme.com"},
|
||||||
|
{"name": "Erin Pope", "email": ""}, # name only -> kept
|
||||||
|
{"name": "", "email": ""}, # fully blank -> dropped
|
||||||
|
{"name": " ", "title": "Ghost"}, # whitespace-only name, no email -> dropped
|
||||||
|
]})
|
||||||
|
check(st == 200, f"name-only-pill update -> 200 (got {st})")
|
||||||
|
by_id, _ = blob_rows()
|
||||||
|
names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
|
||||||
|
check(names == ["Jane Doe", "Erin Pope"],
|
||||||
|
f"name-only pill kept, blank/whitespace pills dropped (got {names})")
|
||||||
|
|
||||||
|
# ── guards ──
|
||||||
|
print("\n[validation guards]")
|
||||||
|
st, _ = _post(port, "/api/fundraising/update-row", token, {"investor_name": "No Id"})
|
||||||
|
check(st == 400, f"missing row_id -> 400 (got {st})")
|
||||||
|
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-nope", "investor_name": "Ghost"})
|
||||||
|
check(st == 404, f"unknown row_id -> 404 (got {st})")
|
||||||
|
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "investor_name": " "})
|
||||||
|
check(st == 400, f"blank name -> 400 (got {st})")
|
||||||
|
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1"})
|
||||||
|
check(st == 400, f"no-op body (neither name nor contacts) -> 400 (got {st})")
|
||||||
|
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": "nope"})
|
||||||
|
check(st == 400, f"contacts wrong type -> 400 (got {st})")
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
|
||||||
|
print()
|
||||||
|
if FAILS:
|
||||||
|
print(f"FAILED ({len(FAILS)}):")
|
||||||
|
for f in FAILS:
|
||||||
|
print(f" - {f}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("ALL PASS (fundraising update-row: version-safe single-row name/pill edit)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+84
-5
@@ -2320,6 +2320,16 @@
|
|||||||
.dedup-box-title { font-size: 12px; color: var(--due-soon, #e0b341); margin-bottom: 4px; }
|
.dedup-box-title { font-size: 12px; color: var(--due-soon, #e0b341); margin-bottom: 4px; }
|
||||||
.dedup-match { font-size: 13px; color: var(--text-secondary); padding: 3px 0; }
|
.dedup-match { font-size: 13px; color: var(--text-secondary); padding: 3px 0; }
|
||||||
|
|
||||||
|
/* P3b — mobile contact-pill editor (the 'edit' sheet: investor name + add/edit/remove pills). */
|
||||||
|
.fs-detail-edit { margin-left: auto; background: transparent; border: none; color: var(--accent); font-size: 15px; font-family: inherit; cursor: pointer; padding: 6px 0 6px 8px; }
|
||||||
|
.pill-edit { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 10px 12px; margin-bottom: 10px; }
|
||||||
|
.pill-edit-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||||
|
.pill-edit-label { font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-muted); text-transform: uppercase; }
|
||||||
|
.pill-edit-remove { background: transparent; border: none; color: var(--danger-soft); font-size: 22px; line-height: 1; cursor: pointer; padding: 0 4px; }
|
||||||
|
.pill-edit .sheet-input { margin-bottom: 8px; }
|
||||||
|
.pill-edit .sheet-input:last-child { margin-bottom: 0; }
|
||||||
|
.sheet-addbtn { width: 100%; min-height: var(--mobile-touch-target); margin-bottom: 14px; background: transparent; border: 1px dashed var(--border); color: var(--accent-light); border-radius: var(--mobile-control-radius); font-size: 14px; font-family: inherit; cursor: pointer; }
|
||||||
|
|
||||||
/* ─── Phase 4 — Pipeline mobile surface (swipe-between-stages) ─────────────────────
|
/* ─── Phase 4 — Pipeline mobile surface (swipe-between-stages) ─────────────────────
|
||||||
JS-gated to MobilePipeline; reuses the .fs-detail / .sheet / .stage-chip patterns.
|
JS-gated to MobilePipeline; reuses the .fs-detail / .sheet / .stage-chip patterns.
|
||||||
Stage segmented control (count-forward) → horizontal scroll-snap stage pages → dots;
|
Stage segmented control (count-forward) → horizontal scroll-snap stage pages → dots;
|
||||||
@@ -9358,12 +9368,12 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mobile Fundraising Grid (<768px) — P3a of the mobile-first redesign. A lean card list →
|
// Mobile Fundraising Grid (<768px) — P3a/P3b of the mobile-first redesign. A lean card list →
|
||||||
// full-screen detail → edit sheets. Reads /api/fundraising/state once; ALL writes go through
|
// full-screen detail → edit sheets. Reads /api/fundraising/state once; ALL writes go through
|
||||||
// the targeted one-row endpoints (log-communication / pipeline link+stage / reminders), NEVER
|
// the targeted one-row endpoints (log-communication / update-row / pipeline link+stage /
|
||||||
// the whole-grid PUT (BRIEF §3a — that would race the 5-person live grid). Editable here:
|
// reminders), NEVER the whole-grid PUT (BRIEF §3a — that would race the 5-person live grid).
|
||||||
// create investor, log a note, pipeline stage, set a reminder. Renaming + contact-pill edits
|
// Editable here: create investor, edit name + contact pills (P3b, via update-row), log a
|
||||||
// on an existing row are read-only in P3a (need a narrow per-row PATCH — deferred to P3b).
|
// note, pipeline stage, set a reminder. Money amounts stay desktop-only (read-only here).
|
||||||
const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView }) => {
|
const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView }) => {
|
||||||
const [columns, setColumns] = useState([]);
|
const [columns, setColumns] = useState([]);
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
@@ -9376,6 +9386,9 @@
|
|||||||
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
|
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
|
||||||
const [noteForm, setNoteForm] = useState({ type: 'note', subject: '', body: '' });
|
const [noteForm, setNoteForm] = useState({ type: 'note', subject: '', body: '' });
|
||||||
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
|
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
|
||||||
|
// P3b edit sheet: investor name + the full contacts array (pills carry their other
|
||||||
|
// fields — title/location/linkedin — through unedited; we only surface name/email/title).
|
||||||
|
const [editForm, setEditForm] = useState({ name: '', contacts: [] });
|
||||||
|
|
||||||
const reload = useCallback(async (silent) => {
|
const reload = useCallback(async (silent) => {
|
||||||
try {
|
try {
|
||||||
@@ -9526,6 +9539,50 @@
|
|||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── edit (name + contact pills) — version-safe one-row POST, never the whole-grid PUT ──
|
||||||
|
const openEdit = () => {
|
||||||
|
const row = selectedRow; if (!row) return;
|
||||||
|
setEditForm({
|
||||||
|
name: row.investor_name || '',
|
||||||
|
contacts: (Array.isArray(row.contacts) ? row.contacts : []).map((c) => ({ ...c })),
|
||||||
|
});
|
||||||
|
setSheet('edit');
|
||||||
|
};
|
||||||
|
const setPill = (i, patch) => setEditForm((f) => ({
|
||||||
|
...f, contacts: f.contacts.map((c, j) => (j === i ? { ...c, ...patch } : c)),
|
||||||
|
}));
|
||||||
|
const addPill = () => setEditForm((f) => ({ ...f, contacts: [...f.contacts, { name: '', email: '', title: '' }] }));
|
||||||
|
const removePill = (i) => setEditForm((f) => ({ ...f, contacts: f.contacts.filter((_, j) => j !== i) }));
|
||||||
|
|
||||||
|
const submitEdit = async () => {
|
||||||
|
const row = selectedRow; if (!row) return;
|
||||||
|
const name = String(editForm.name || '').trim();
|
||||||
|
if (!name) { onShowToast('Investor name is required', 'error'); return; }
|
||||||
|
// Client-side dedup (BRIEF §3a): drop blank pills, then collapse duplicates by email
|
||||||
|
// (preferred key) else by name — keeping each pill's preserved fields on the survivor.
|
||||||
|
const seen = new Set();
|
||||||
|
const contacts = [];
|
||||||
|
for (const c of editForm.contacts) {
|
||||||
|
const cn = String(c.name || '').trim();
|
||||||
|
const ce = String(c.email || '').trim();
|
||||||
|
if (!cn && !ce) continue;
|
||||||
|
const key = ce ? `e:${ce.toLowerCase()}` : `n:${cn.toLowerCase()}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
contacts.push({ ...c, name: cn, email: ce, title: String(c.title || '').trim() });
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api('/api/fundraising/update-row', { method: 'POST', body: JSON.stringify({
|
||||||
|
row_id: row.id, investor_name: name, contacts,
|
||||||
|
}) }, token);
|
||||||
|
onShowToast('Investor updated', 'success');
|
||||||
|
closeSheet();
|
||||||
|
await reload(true);
|
||||||
|
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to update investor'), 'error'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
const renderCard = (row) => {
|
const renderCard = (row) => {
|
||||||
const committed = gridRollup(row, fundColumnIds);
|
const committed = gridRollup(row, fundColumnIds);
|
||||||
const days = daysSince(row.last_activity_at);
|
const days = daysSince(row.last_activity_at);
|
||||||
@@ -9619,6 +9676,7 @@
|
|||||||
<div className="fs-detail" role="dialog" aria-modal="true">
|
<div className="fs-detail" role="dialog" aria-modal="true">
|
||||||
<div className="fs-detail-header">
|
<div className="fs-detail-header">
|
||||||
<button className="fs-detail-back" onClick={() => setSelectedId(null)}>‹ Grid</button>
|
<button className="fs-detail-back" onClick={() => setSelectedId(null)}>‹ Grid</button>
|
||||||
|
<button className="fs-detail-edit" onClick={openEdit}>Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="fs-detail-body">
|
<div className="fs-detail-body">
|
||||||
<div className="fs-detail-id">
|
<div className="fs-detail-id">
|
||||||
@@ -9729,6 +9787,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<button className="sheet-submit" onClick={submitReminder} disabled={busy}>{busy ? 'Saving…' : 'Set reminder'}</button>
|
<button className="sheet-submit" onClick={submitReminder} disabled={busy}>{busy ? 'Saving…' : 'Set reminder'}</button>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|
||||||
|
<BottomSheet open={sheet === 'edit'} onClose={closeSheet} title="Edit investor">
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Investor name</label>
|
||||||
|
<input className="sheet-input" value={editForm.name} onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. Acme Capital" />
|
||||||
|
</div>
|
||||||
|
<label className="sheet-field-label">Contacts</label>
|
||||||
|
{editForm.contacts.map((c, i) => (
|
||||||
|
<div className="pill-edit" key={i}>
|
||||||
|
<div className="pill-edit-head">
|
||||||
|
<span className="pill-edit-label">Contact {i + 1}</span>
|
||||||
|
<button className="pill-edit-remove" type="button" aria-label="Remove contact" onClick={() => removePill(i)}>×</button>
|
||||||
|
</div>
|
||||||
|
<input className="sheet-input" value={c.name || ''} onChange={(e) => setPill(i, { name: e.target.value })} placeholder="Full name" />
|
||||||
|
<input className="sheet-input" type="email" value={c.email || ''} onChange={(e) => setPill(i, { email: e.target.value })} placeholder="name@firm.com" />
|
||||||
|
<input className="sheet-input" value={c.title || ''} onChange={(e) => setPill(i, { title: e.target.value })} placeholder="Title (optional)" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="sheet-addbtn" type="button" onClick={addPill}>+ Add contact</button>
|
||||||
|
<button className="sheet-submit" onClick={submitEdit} disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button>
|
||||||
|
</BottomSheet>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
Reference in New Issue
Block a user