@series-a/cli 1.2.0 → 1.3.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to `@series-a/cli` (and the paired `@series-a/mcp` server).
4
4
 
5
+ ## Unreleased — claims review dashboard
6
+
7
+ ### Added
8
+ - `setu claims` — review dashboard showing pending claim requests, counts by status and unclaimed profiles scored for readiness (with what's still missing)
9
+ - `setu claims show|approve|decline <claim-id>` (`--reason`, `--yes`), plus `setu claims --status approved|rejected|all`
10
+ - MCP tools `setu_claims_dashboard`, `setu_claims_list`, `setu_claim_detail` and `setu_claim_decide`
11
+
12
+ ## 1.3.0 — lists, full profiles and image uploads
13
+
14
+ ### Added
15
+ - `practice list` and `dentist list` — browse seeded profiles, with `--limit`, `--search` and (for clinicians) `--practice`
16
+ - `setu status <id> --full` and `setu status --dentist` — the complete enriched profile including photos, prices, services and attached clinicians
17
+ - `setu <type> images --upload <file|url>` and `--upload-logo <file|url>` — upload photos straight into Setu storage with your CLI key, no third-party hosting needed
18
+ - `dentist create` now honours `--photo`, `--gallery`, `--phone`, `--website`, `--booking` and `--bio`
19
+ - MCP tool `setu_profile_upload_image`; `setu_profile_status` gained `full`, `list`, `limit`, `search` and `practice_id`; `setu_dentist_create` gained photo/gallery/contact fields
20
+
5
21
  ## 1.2.0 — booking links and full profile-settings editing
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -48,6 +48,8 @@ are listed at the end so you can retry them.
48
48
  setu practice claim-link <practice-id>
49
49
  setu status # every seeded profile and its claim state
50
50
  setu status <practice-id> # one profile plus its claim attempts
51
+ setu status <profile-id> --full # every field, photos, prices, services, clinicians
52
+ setu status --dentist # list clinicians instead of practices
51
53
  ```
52
54
 
53
55
  Send the claim link in outreach. The practice signs in, and is verified either
@@ -56,10 +58,31 @@ contact address on their Google listing. On success the profile transfers to
56
58
  them, pre-filled, and they continue through the normal onboarding and
57
59
  subscription flow.
58
60
 
61
+ ## Reviewing claims
62
+
63
+ ```bash
64
+ setu claims # pending claims + unclaimed profiles by readiness
65
+ setu claims --ready # only profiles ready to promote
66
+ setu claims --status all # every claim, any status
67
+ setu claims show <claim-id> # claimant, method, attempts, target profile
68
+ setu claims approve <claim-id> # transfer the profile to the claimant
69
+ setu claims decline <claim-id> --reason "Not the practice owner"
70
+ ```
71
+
72
+ Approving a practice claim makes the claimant the owner, adds an owner
73
+ membership and marks the practice's clinicians as claimed. A claim can only be
74
+ approved once the claimant has signed in, so there's an account to transfer to.
75
+
59
76
  ## Clinicians
60
77
 
61
78
  ```bash
62
- setu dentist create --name "Dr Jane Roe" --practice <id> --profile-url https://…/team/jane
79
+ setu dentist create --name "Dr Jane Roe" --practice <id> --profile-url https://…/team/jane \
80
+ --photo https://…/jane.jpg --gallery "https://…/a.jpg,https://…/b.jpg" \
81
+ --phone 02392000000 --website https://… --booking https://…/book
82
+
83
+ setu practice list --limit 100 --search "smile"
84
+ setu dentist list --practice <practice-id>
85
+ setu dentist status <dentist-id> --full
63
86
  ```
64
87
 
65
88
  ## Flags
@@ -126,6 +149,15 @@ setu practice images <practice-id> --logo https://.../logo.png
126
149
  setu dentist images <dentist-id> --logo https://.../headshot.jpg # main photo
127
150
  ```
128
151
 
152
+ Upload from your machine (or copy a remote image into Setu storage) instead of
153
+ linking to someone else's server:
154
+
155
+ ```bash
156
+ setu practice images <practice-id> --upload ./reception.jpg
157
+ setu practice images <practice-id> --upload-logo ./logo.png
158
+ setu dentist images <dentist-id> --upload-logo https://.../headshot.jpg
159
+ ```
160
+
129
161
  ## Prices and services
130
162
 
131
163
  ```bash
package/bin/setu.mjs CHANGED
@@ -77,16 +77,29 @@ ${c.bold("Commands")}
77
77
  practice images <practice-id> Manage photos on a seeded profile
78
78
  [--add <url>] [--remove <url|index>] [--order "url1,url2"]
79
79
  [--logo <url>] [--replace "url1,url2"] [--clear] [--list]
80
+ [--upload <file|url>] [--upload-logo <file|url>]
80
81
  practice pricing <practice-id> Manage treatment prices
81
82
  [--set "Implant=1200"] [--remove "Implant"] [--clear] [--list]
83
+ practice list List seeded practices
84
+ [--limit 50] [--search <name>]
82
85
  dentist create --name "Dr Jane Roe" Seed one clinician
83
86
  [--practice <id>] [--profile-url <url>] [--town <town>]
87
+ [--photo <url>] [--gallery "url1,url2"] [--phone <n>]
88
+ [--website <url>] [--booking <url>] [--bio "..."]
89
+ dentist list List seeded clinicians
90
+ [--limit 50] [--search <name>] [--practice <id>]
84
91
  dentist update <dentist-id> Edit fields on a seeded clinician
85
92
  dentist images <dentist-id> Manage a clinician's photos
86
93
  dentist pricing <dentist-id> Manage a clinician's treatment prices
87
94
  dentist delete <dentist-id> Remove an unclaimed seeded clinician
88
95
  [--unpublish] [--yes]
89
- status [<practice-id>] List or inspect seeded profiles
96
+ status [<profile-id>] List or inspect seeded profiles
97
+ [--dentist] [--full] [--limit 50] [--search <name>]
98
+ claims Review dashboard: pending claims + ready profiles
99
+ [--status pending|approved|rejected|all] [--dentist] [--ready] [--limit 50]
100
+ claims show <claim-id> Full detail for one claim
101
+ claims approve <claim-id> Hand the profile to the claimant
102
+ claims decline <claim-id> Decline a claim [--reason "..."]
90
103
 
91
104
  ${c.bold("Global")} --json --yes --verbose --operator <name> --version
92
105
  `);
@@ -188,8 +201,45 @@ async function cmdProfileUpdate(type, id) {
188
201
  if (res.rejected?.length) warn(`Ignored unknown field(s): ${res.rejected.join(", ")}`);
189
202
  }
190
203
 
204
+ const MIME_BY_EXT = {
205
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
206
+ webp: "image/webp", svg: "image/svg+xml", gif: "image/gif",
207
+ };
208
+
209
+ /** A local path becomes base64; a URL is fetched server-side. */
210
+ function uploadPayload(src) {
211
+ const value = String(src);
212
+ if (/^https?:\/\//i.test(value)) return { source_url: value };
213
+ const ext = value.split(".").pop().toLowerCase();
214
+ return {
215
+ data: readFileSync(value).toString("base64"),
216
+ content_type: MIME_BY_EXT[ext] || "image/jpeg",
217
+ filename: value.split(/[\\/]/).pop(),
218
+ };
219
+ }
220
+
221
+ async function uploadImages(type, id, sources, attach) {
222
+ const urls = [];
223
+ for (const src of sources) {
224
+ const s = spinner(`Uploading ${src}`);
225
+ try {
226
+ const res = await call("cli-profile-upload", { id, profile_type: type, attach, ...uploadPayload(src) });
227
+ s.stop();
228
+ urls.push(res.url);
229
+ if (!JSON_OUT) ok(`${attach === "logo" ? "Logo" : "Photo"} uploaded — ${c.dim(res.url)}`);
230
+ } catch (e) { s.stop(); throw e; }
231
+ }
232
+ return urls;
233
+ }
234
+
191
235
  async function cmdProfileImages(type, id) {
192
- if (!id) { fail(`Usage: setu ${type} images <id> [--add <url>] [--remove <url|index>] [--order "a,b"] [--logo <url>]`); process.exit(1); }
236
+ if (!id) { fail(`Usage: setu ${type} images <id> [--add <url>] [--upload <file>] [--remove <url|index>] [--order "a,b"] [--logo <url>] [--upload-logo <file>]`); process.exit(1); }
237
+
238
+ const uploads = splitList(flags.upload);
239
+ const logoUploads = splitList(flags["upload-logo"]);
240
+ const uploaded = [];
241
+ if (uploads.length) uploaded.push(...await uploadImages(type, id, uploads, "gallery"));
242
+ if (logoUploads.length) await uploadImages(type, id, logoUploads.slice(0, 1), "logo");
193
243
 
194
244
  const images = {};
195
245
  if (splitList(flags.add).length) images.add = splitList(flags.add);
@@ -521,34 +571,170 @@ async function cmdDentistCreate() {
521
571
  town: typeof flags.town === "string" ? flags.town : undefined,
522
572
  postcode: typeof flags.postcode === "string" ? flags.postcode : undefined,
523
573
  email: typeof flags.email === "string" ? flags.email : undefined,
574
+ phone: typeof flags.phone === "string" ? flags.phone : undefined,
575
+ website_url: typeof flags.website === "string" ? flags.website : undefined,
576
+ booking_url: typeof flags.booking === "string" ? flags.booking : undefined,
577
+ bio: typeof flags.bio === "string" ? flags.bio : undefined,
578
+ photo_url: typeof flags.photo === "string" ? flags.photo : undefined,
579
+ gallery: splitList(flags.gallery),
524
580
  });
525
581
  s.stop();
526
582
  if (JSON_OUT) return out(res);
527
583
  ok("Clinician seeded as unclaimed");
528
- table([["Dentist", res.dentist_id], ["AI enriched", res.enriched ? "yes" : "no"], ["Claim link", res.claim_url]]);
584
+ table([
585
+ ["Dentist", res.dentist_id],
586
+ ["AI enriched", res.enriched ? "yes" : "no"],
587
+ ["Photo", res.photo_url ?? "—"],
588
+ ["Claim link", res.claim_url],
589
+ ]);
529
590
  } catch (e) { s.stop(); throw e; }
530
591
  }
531
592
 
532
- async function cmdStatus(id) {
533
- const res = await call("cli-profile-status", id ? { id } : { list: true, limit: flags.limit });
593
+ function printProfileList(res) {
594
+ heading(`Seeded ${res.profile_type === "dentist" ? "clinicians" : "practices"} (${res.items.length})`);
595
+ for (const p of res.items) {
596
+ const state = p.claim_status === "claimed" ? c.green("claimed") : p.is_live ? c.green("live") : c.yellow(p.claim_status);
597
+ log(` ${state.padEnd(20)} ${String(p.name).slice(0, 38).padEnd(40)} ${c.dim(p.id)}`);
598
+ }
599
+ }
600
+
601
+ async function cmdProfileList(type) {
602
+ const res = await call("cli-profile-status", {
603
+ list: true,
604
+ profile_type: type,
605
+ limit: flags.limit,
606
+ search: typeof flags.search === "string" ? flags.search : undefined,
607
+ practice_id: type === "dentist" && typeof flags.practice === "string" ? flags.practice : undefined,
608
+ });
534
609
  if (JSON_OUT) return out(res);
535
- if (res.items) {
536
- heading(`Seeded profiles (${res.items.length})`);
537
- for (const p of res.items) {
538
- const state = p.claim_status === "claimed" ? c.green("claimed") : p.is_live ? c.green("live") : c.yellow(p.claim_status);
539
- log(` ${state.padEnd(20)} ${String(p.name).slice(0, 38).padEnd(40)} ${c.dim(p.id)}`);
610
+ printProfileList(res);
611
+ }
612
+
613
+ async function cmdStatus(id, type = flags.dentist ? "dentist" : "practice") {
614
+ const res = await call(
615
+ "cli-profile-status",
616
+ id
617
+ ? { id, profile_type: type, full: !!flags.full }
618
+ : { list: true, profile_type: type, limit: flags.limit, search: typeof flags.search === "string" ? flags.search : undefined },
619
+ );
620
+ if (JSON_OUT) return out(res);
621
+ if (res.items) return printProfileList(res);
622
+
623
+ const p = res.profile ?? res.practice;
624
+ heading(p.name);
625
+ table([
626
+ ["Type", res.profile_type],
627
+ ["Status", p.claim_status],
628
+ ["Live", p.is_live ? "yes" : "no"],
629
+ ["Source", `${p.source} (${p.created_by_operator ?? "—"})`],
630
+ ["Claim link", p.claim_url],
631
+ ]);
632
+
633
+ if (flags.full) {
634
+ const gallery = res.gallery ?? [];
635
+ heading(`Photos (${gallery.length})`);
636
+ gallery.forEach((url, i) => log(` ${String(i).padEnd(3)} ${c.dim(url)}`));
637
+ log(` ${c.dim(res.profile_type === "dentist" ? "main photo" : "logo")}: ${res.logo ?? c.dim("none")}`);
638
+
639
+ const pricing = res.pricing ?? [];
640
+ heading(`Prices (${pricing.length})`);
641
+ for (const row of pricing) {
642
+ const amount = row.amount_to ? `${row.amount}–${row.amount_to}` : row.amount ?? "—";
643
+ log(` ${String(row.label).slice(0, 38).padEnd(40)} ${amount}`);
644
+ }
645
+
646
+ if (res.dentists) {
647
+ heading(`Clinicians (${res.dentists.length})`);
648
+ for (const d of res.dentists) log(` ${String(d.name).slice(0, 38).padEnd(40)} ${c.dim(d.id)}`);
649
+ }
650
+ }
651
+
652
+ heading(`Claims (${res.claims.length})`);
653
+ for (const cl of res.claims) log(` ${cl.status.padEnd(10)} ${cl.claimant_email} ${c.dim(cl.method)}`);
654
+ }
655
+
656
+ function printClaimRow(cl) {
657
+ const who = String(cl.claimant_email ?? "—").slice(0, 30).padEnd(32);
658
+ const name = String(cl.profile?.name ?? cl.profile_type ?? "—").slice(0, 26).padEnd(28);
659
+ const flag = cl.awaiting_signin ? c.yellow(" (not signed in)") : "";
660
+ log(` ${String(cl.status).padEnd(9)} ${who} ${name} ${c.dim(cl.method ?? "")}${flag}`);
661
+ log(` ${c.dim(cl.id)}`);
662
+ }
663
+
664
+ async function cmdClaims(sub, arg) {
665
+ if (sub === "show") {
666
+ if (!arg) throw new Error("Usage: setu claims show <claim-id>");
667
+ const res = await call("cli-claims", { action: "detail", claim_id: arg });
668
+ if (JSON_OUT) return out(res);
669
+ heading(`Claim ${res.claim.id}`);
670
+ table([
671
+ ["Status", res.claim.status],
672
+ ["Claimant", res.claim.claimant_email],
673
+ ["Method", res.claim.method],
674
+ ["Attempts", res.claim.attempts],
675
+ ["Signed in", res.claim.claimant_user_id ? "yes" : "no"],
676
+ ["Profile", res.profile ? `${res.profile.name} (${res.claim.profile_type})` : "—"],
677
+ ["Readiness", res.profile ? `${res.profile.score}%` : "—"],
678
+ ["Missing", res.profile?.missing?.join(", ")],
679
+ ["Claim link", res.profile?.claim_url],
680
+ ]);
681
+ return;
682
+ }
683
+
684
+ if (sub === "approve" || sub === "decline" || sub === "reject") {
685
+ if (!arg) throw new Error(`Usage: setu claims ${sub} <claim-id>`);
686
+ if (!flags.yes && !JSON_OUT) {
687
+ const go = await confirm(`${sub === "approve" ? "Approve" : "Decline"} claim ${arg}?`);
688
+ if (!go) return warn("Cancelled.");
540
689
  }
690
+ const res = await call("cli-claims", {
691
+ action: sub === "approve" ? "approve" : "decline",
692
+ claim_id: arg,
693
+ reason: typeof flags.reason === "string" ? flags.reason : undefined,
694
+ });
695
+ if (JSON_OUT) return out(res);
696
+ return ok(`Claim ${res.status} — ${res.claim_id}`);
697
+ }
698
+
699
+ if (sub && sub !== "list") throw new Error(`Unknown command: claims ${sub}`);
700
+
701
+ const status = typeof flags.status === "string" ? flags.status : "pending";
702
+ const profileType = flags.dentist ? "dentist" : undefined;
703
+ if (sub === "list" || (typeof flags.status === "string" && flags.status !== "pending")) {
704
+ const res = await call("cli-claims", { action: "list", status, profile_type: profileType, limit: flags.limit });
705
+ if (JSON_OUT) return out(res);
706
+ heading(`Claims — ${status} (${res.claims.length})`);
707
+ res.claims.forEach(printClaimRow);
708
+ if (!res.claims.length) log(c.dim(" none"));
541
709
  return;
542
710
  }
543
- heading(res.practice.name);
711
+
712
+ const res = await call("cli-claims", {
713
+ action: "dashboard",
714
+ profile_type: profileType,
715
+ ready_only: !!flags.ready,
716
+ limit: flags.limit,
717
+ });
718
+ if (JSON_OUT) return out(res);
719
+
720
+ heading("Claims");
544
721
  table([
545
- ["Status", res.practice.claim_status],
546
- ["Live", res.practice.is_live ? "yes" : "no"],
547
- ["Source", `${res.practice.source} (${res.practice.created_by_operator ?? "—"})`],
548
- ["Claim link", res.practice.claim_url],
722
+ ["Pending", res.counts.pending],
723
+ ["Approved", res.counts.approved],
724
+ ["Rejected", res.counts.rejected],
549
725
  ]);
550
- heading(`Claims (${res.claims.length})`);
551
- for (const cl of res.claims) log(` ${cl.status.padEnd(10)} ${cl.claimant_email} ${c.dim(cl.method)}`);
726
+
727
+ heading(`Pending review (${res.pending_claims.length})`);
728
+ res.pending_claims.forEach(printClaimRow);
729
+ if (!res.pending_claims.length) log(c.dim(" nothing waiting"));
730
+
731
+ heading(`Unclaimed profiles — ${res.unclaimed.ready}/${res.unclaimed.total} ready, ${res.unclaimed.not_live} ready but offline`);
732
+ for (const u of res.unclaimed.items) {
733
+ const badge = u.ready ? c.green("ready") : c.yellow("gaps ");
734
+ log(` ${badge} ${String(u.readiness_score + "%").padStart(4)} ${String(u.name).slice(0, 30).padEnd(32)} ${c.dim(u.profile_type)}${u.is_live ? "" : c.dim(" offline")}`);
735
+ if (u.missing.length) log(` ${c.dim("missing: " + u.missing.join(", "))}`);
736
+ }
737
+ if (!res.unclaimed.items.length) log(c.dim(" none"));
552
738
  }
553
739
 
554
740
  async function main() {
@@ -564,6 +750,7 @@ async function main() {
564
750
  case "logout": return cmdLogout();
565
751
  case "whoami": return cmdWhoami();
566
752
  case "status": return cmdStatus(sub);
753
+ case "claims": return cmdClaims(sub, arg);
567
754
  case "practice":
568
755
  switch (sub) {
569
756
  case "create": return cmdPracticeCreate();
@@ -575,6 +762,7 @@ async function main() {
575
762
  case "update": return cmdProfileUpdate("practice", arg);
576
763
  case "images": return cmdProfileImages("practice", arg);
577
764
  case "pricing": return cmdProfilePricing("practice", arg);
765
+ case "list": return cmdProfileList("practice");
578
766
  default:
579
767
  fail(`Unknown command: practice ${sub ?? ""}`.trim());
580
768
  usage();
@@ -583,6 +771,8 @@ async function main() {
583
771
  }
584
772
  case "dentist":
585
773
  if (sub === "create") return cmdDentistCreate();
774
+ if (sub === "list") return cmdProfileList("dentist");
775
+ if (sub === "status") return cmdStatus(arg, "dentist");
586
776
  if (sub === "update") return cmdProfileUpdate("dentist", arg);
587
777
  if (sub === "images") return cmdProfileImages("dentist", arg);
588
778
  if (sub === "pricing") return cmdProfilePricing("dentist", arg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-a/cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "private": false,
5
5
  "description": "Setu CLI — build, review and publish claimable practice and dentist profiles.",
6
6
  "type": "module",