@feastalytics/cli 0.1.15 → 0.1.17
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/README.md +13 -2
- package/dist/cli.js +376 -219
- package/feast/SKILL.md +72 -46
- package/feast/references/domains.md +9 -7
- package/feast/references/links.md +11 -11
- package/feast/references/setup.md +41 -20
- package/feast/references/workflows/ad-copy-creator.md +26 -28
- package/feast/references/workflows/ad-copy-guest.md +28 -30
- package/feast/references/workflows/ads.md +39 -27
- package/feast/references/workflows/automations.md +36 -34
- package/feast/references/workflows/campaigns.md +56 -24
- package/feast/references/workflows/creators.md +40 -36
- package/feast/references/workflows/funnels.md +15 -19
- package/feast/references/workflows/guests.md +27 -11
- package/feast/references/workflows/members-program.md +17 -13
- package/feast/references/workflows/onboarding.md +16 -17
- package/package.json +1 -1
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# Creator sourcing
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Restaurants recruit local creators to visit and post about them. A recruitment ad brings applicants in; from there it's a queue of decisions — approve the applicant, then later approve the content they made. Both decisions text the creator, so neither is a quiet status change.
|
|
3
|
+
Restaurants recruit local creators to visit and post about them. A recruitment ad brings applicants in; from there it's a queue of decisions: approve the applicant, then later approve the content they made. Both decisions text the creator, so neither is a quiet status change.
|
|
6
4
|
|
|
7
5
|
### The model: the application IS the visit row
|
|
8
6
|
|
|
@@ -10,81 +8,87 @@ There is no separate application object. One row covers a creator's whole journe
|
|
|
10
8
|
|
|
11
9
|
- `approvalStatus` `pending_approval` → awaiting your decision, then `approved` or `denied`.
|
|
12
10
|
- `startTime` **null** on an approved row → they're approved but haven't booked yet. Set → scheduled.
|
|
13
|
-
- `preVisitConfirmationStatus` `confirmed` → they confirmed they're
|
|
11
|
+
- `preVisitConfirmationStatus` `confirmed` → they confirmed they're coming.
|
|
14
12
|
- `postVisitFollowUpSentAt` set → the visit is done.
|
|
15
13
|
|
|
16
14
|
**Gotcha:** denying an application stamps `postVisitFollowUpSentAt` and every content follow-up with the current time, as the way to suppress the remaining message sequence. So a denied row looks *completed* on those timestamps. Always pair a timestamp check with `approvalStatus == "approved"`.
|
|
17
15
|
|
|
18
16
|
### Setting up the program
|
|
19
17
|
|
|
20
|
-
The program lives on a **location**, not the organization
|
|
18
|
+
The program lives on a **location**, not the organization: one config per `locationId`, which you get from `queryData interface.location`.
|
|
19
|
+
|
|
20
|
+
`updateInfluencerBoardConfig` is an **upsert**. There is no create tool: call it for a location with no program and it writes one, seeding a 5000-cent dining credit and leaving `landingPageConfirmed`, `passConfigured` and `reimbursementEnabled` false. Omitted fields are left alone on subsequent calls. `foodCreditAmountCents` has a floor of 2500.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
**Every program is apply-only.** Creators apply, the approver reviews them, and the creator AI agent texts approved creators to book the visit. There is no scheduling mode to choose.
|
|
23
23
|
|
|
24
|
-
**
|
|
24
|
+
**The setup task and the launch check agree.** The *Design creator program* task and the launch both need a positive credit and `landingPageConfirmed: true`. Since the credit is seeded at 5000 and can't go below 2500, `landingPageConfirmed` is the one field you actually have to set.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Other fields worth knowing on the same call:
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
- **`maxCreatorsPerMonth`** caps how many creators the location's recruitment ads source per calendar month. When the cap is reached every recruitment campaign at the location pauses automatically until the 1st of the next month; changing or clearing the cap (`null`) reconciles the campaigns immediately, so raising it can restart paused ads.
|
|
29
|
+
- **`agentPaused: true`** turns the creator AI agent off for the location: no AI replies, visit reminders or content follow-ups until it is set back to `false`. Texts sent by people (including `sendText`) deliver as usual.
|
|
30
|
+
- **`reimbursementEnabled`** switches the board from comping the meal to reimbursing a meal the creator paid for, and `foodCreditAmountCents` becomes the reimbursement cap rather than a dining credit. It changes what creators are promised on the landing page, brief and rights agreement, so **never set it unless the client asks for it**. See *Reimbursing boards* below.
|
|
31
|
+
|
|
32
|
+
`getInfluencerBoardConfig` returns the config (or `null` when the location has no program), including the location's recruitment Meta campaign, ad set and saved status (`recruitmentFacebookCampaignId`, `recruitmentFacebookAdSetId`, `recruitmentStatus`). **Read it before writing recruitment copy**: the dining credit, creator bonus and follower minimum you're supposed to quote live here and nowhere else. It's also how you check the bonus is non-zero before calling `decideCreatorSubmission` with `approvalType: "ad"`.
|
|
29
33
|
|
|
30
34
|
### Booking windows
|
|
31
35
|
|
|
32
|
-
`listAvailability` (no arguments, **org-wide
|
|
36
|
+
`listAvailability` (no arguments, **org-wide**: filter by `locationId` or `campaignId` yourself), `createAvailability`, `updateAvailability`, `deleteAvailability`.
|
|
33
37
|
|
|
34
38
|
A window's `block` is one of two shapes: `once`, with a `utcStart` and `utcEnd`; or `weekly`, with start and end hour/minute, the `utcDaysOfWeek` it repeats on, and `blockUtcStart` for when the repetition begins.
|
|
35
39
|
|
|
36
|
-
**Everything is UTC and the restaurant will describe it in local time.** For weekly blocks `utcDaysOfWeek` is the day of week *in UTC*, so an evening local window that crosses midnight UTC lands on the **following** day
|
|
40
|
+
**Everything is UTC and the restaurant will describe it in local time.** For weekly blocks `utcDaysOfWeek` is the day of week *in UTC*, so an evening local window that crosses midnight UTC lands on the **following** day: 9pm Friday New York is 02:00 Saturday UTC, and writing `Friday` there opens the wrong night. Convert the day and the time together, never just the time. This fails silently: you get a valid window on a day nobody asked for. The same applies in reverse when you read `listAvailability` back: convert each window to local day and time before describing it to the restaurant.
|
|
37
41
|
|
|
38
|
-
**Set `campaignId`, not just `locationId`.** It's optional in the schema and required by the task
|
|
42
|
+
**Set `campaignId`, not just `locationId`.** It's optional in the schema and required by the task: *Set booking windows* completes only when a window carries the first campaign's id. Without it the window books fine and the task stays open forever.
|
|
39
43
|
|
|
40
|
-
`updateAvailability` replaces `block` whole rather than merging it, so send the complete block including the parts you aren't changing, and it returns nothing
|
|
44
|
+
`updateAvailability` replaces `block` whole rather than merging it, so send the complete block including the parts you aren't changing, and it returns nothing: re-read with `listAvailability` to confirm. `deleteAvailability` **succeeds silently on an id that doesn't exist**, so no error is not proof anything was removed; take ids from `listAvailability`. Deleting closes future slots but does not cancel visits already booked inside the window; those are separate rows.
|
|
41
45
|
|
|
42
46
|
### The creative brief
|
|
43
47
|
|
|
44
48
|
`createCreativeStrategy` has two paths behind one tool, and only one of them finishes synchronously:
|
|
45
49
|
|
|
46
|
-
- **`awareness
|
|
47
|
-
- **`cta
|
|
48
|
-
|
|
49
|
-
`getCreativeStrategy` is the one tool that takes `organizationId` in its input rather than from `--org` — it also serves the creator-facing brief pages. Pass the organization you're acting on.
|
|
50
|
+
- **`awareness`**: assembled from a fixed template and saved before the call returns. `generationStatus` comes back `complete`.
|
|
51
|
+
- **`cta`**: handed to a background LLM. You get a `strategyId` and `generationStatus: "generating"` immediately. **Poll `getCreativeStrategy` until it reads `complete` or `failed`** before using the brief or quoting anything from it. The `jobId` and `jobType` that come back track the same run through `getJob`; reach for that only when the strategy reads `failed` and you want the job's `errorMessage`.
|
|
50
52
|
|
|
51
|
-
`updateCreativeStrategy` is the revision step. Two things to get right: omitting `strategyId` **creates a new strategy** instead of editing the one you meant, and it replaces the fields you send rather than merging them
|
|
53
|
+
`updateCreativeStrategy` is the revision step. Two things to get right: omitting `strategyId` **creates a new strategy** instead of editing the one you meant, and it replaces the fields you send rather than merging them, so read first, apply your edits to the full `concepts` array, and send the whole thing back. Generating into a strategy that isn't a draft is rejected rather than silently overwritten.
|
|
52
54
|
|
|
53
55
|
### Recruitment creatives and the recruitment ad
|
|
54
56
|
|
|
55
|
-
The ads that bring applicants in are
|
|
57
|
+
The ads that bring applicants in are tool-drivable end to end:
|
|
56
58
|
|
|
57
|
-
1. `createRecruitmentCreatives` with the `campaignId`
|
|
58
|
-
2. `listCreatives
|
|
59
|
-
3. Publish through the `recruitment` template in `ads.md`, declaring the **`linkRecruitmentOffer` effect
|
|
59
|
+
1. `createRecruitmentCreatives` with `{ "locationId": "...", "foodCredit": ..., "campaignId": "..." }`. `locationId` and `foodCredit` are required; take the credit from `getInfluencerBoardConfig`. Pass `campaignId` and the tool resolves (or creates) the campaign's recruitment offer itself, which is what groups the creatives and carries the monthly sourcing cap; pass `offerId` instead only when you already have the exact offer. One of the two is needed, or the creatives are generated, charged for, and attached to nothing. Each run calls an image model per missing type; `force` deletes and regenerates the whole set, so don't pass it casually.
|
|
60
|
+
2. `listCreatives`: each creative's `imageKey` is the reference `planAds` takes as a `libraryAsset` (`selectedImageUrl` is the same picked render as a URL). `imageUrl` is the base render, not the ad asset, so don't choose among the image fields yourself. `staleCreativeIds` flags creatives generated from an older version of their offer, and is only populated when you pass `offerId`.
|
|
61
|
+
3. Publish through the `recruitment` template in `ads.md`, declaring the **`linkRecruitmentOffer` effect**; the publish is refused without it. The effect stamps the creatives, links the offer (which the sourcing cap and dashboard spend read), and texts the program's approver that sourcing is live.
|
|
60
62
|
4. Copy rules for the ad live in `ad-copy-creator.md` (`recruitmentAdCopy`; conflating it with guest copy is the classic failure).
|
|
61
63
|
|
|
62
64
|
### The decision loop
|
|
63
65
|
|
|
64
|
-
1. `listCreatorApplications
|
|
65
|
-
2. `updateCreatorVisit` with `{ "eventId": "...", "status": "approved" | "denied" }`. **This texts the creator immediately
|
|
66
|
+
1. `listCreatorApplications`: the approval queue, newest first, across every location. Takes no arguments. Use this rather than querying the data model: it carries **`instagramFollowerCount`**, which is usually the deciding factor and isn't reachable any other way. Each row also carries the brief assigned to the visit as `strategyId`/`strategyTitle`, with its `campaignId`/`campaignName`, all null when no brief is assigned. **Check `strategyId` is non-null before approving**: the approval text links whatever brief the visit carries at that moment. No tool assigns a brief to a visit, so when it is null, have the user assign one on the Creator approvals page first.
|
|
67
|
+
2. `updateCreatorVisit` with `{ "eventId": "...", "status": "approved" | "denied" }`. **This texts the creator immediately**: approved sends their booking link and creative brief, denied sends a decline. A denial is reversible: approving a denied row later sends a "we changed our mind" text and re-arms the scheduled texts. Approval also **consumes the location's monthly creator sourcing allowance**, and recruitment auto-pauses once that limit is reached, so an approval is both a message and a spend. Confirm with the user before working through a queue; don't batch-approve on your own initiative. **Preview first with `dryRun: true`**: it returns the exact creator text(s) the same call would send and writes nothing, so show the user that before the real call. Approving a row that isn't actionable is a no-op and comes back with `changed: false` rather than texting twice.
|
|
66
68
|
|
|
67
|
-
The same tool is how you reschedule and how you record what happened. `startTime`
|
|
68
|
-
3. The creator books, visits, and submits content on their own
|
|
69
|
-
4. `listCreatorSubmissions` with `{ "status": "submitted" }` (and `"revision_requested"`)
|
|
70
|
-
5. `decideCreatorSubmission
|
|
69
|
+
The same tool is how you reschedule and how you record what happened. `startTime` set to a date texts the creator a confirmation and alerts the approver; `null` clears the time and texts the creator asking for a new one. `startTime` is rejected while the row is `pending_approval` and in any call that passes `status: "approved"`, so approve first, then set the time in a second call (`status: "pending_approval"` clears the time itself; don't pass `startTime` with it). `status` also accepts `confirmed`, `visited`, `missed`, `issue` and `cancelled`; of these only `cancelled` texts the creator. `locationId` moves the visit to another location with a creator program and texts no one, so tell the creator yourself. `notes` sets staff notes shown on the scanner, never sent to the creator. Pass `sideEffects: false` to make any update silent (same field writes, but no creator text, no allowance spend, no post-approval automation), which is what you want when correcting a record after the fact rather than making the decision now.
|
|
70
|
+
3. The creator books, visits, and submits content on their own; none of that is driven from here.
|
|
71
|
+
4. `listCreatorSubmissions` with `{ "status": "submitted" }` (and `"revision_requested"`): the content review queue. Submissions are stored outside the queryable data model, so this tool is the only way to read them.
|
|
72
|
+
5. `decideCreatorSubmission`: `approved`, `rejected`, `revision_requested`, or `under_review`. **Approving texts the creator too**, unless you pass `skipApprovalText: true` (use that only for silent record corrections). `revision_requested` always texts: it sends your `feedbackMessage` verbatim plus a resubmit link, so write it as something the creator will read, not an internal note. **Always pass `approvalType` explicitly when approving**, because an omitted one means `"ad"`: `"ad"` means the content may run in paid ads, stamps the board's bonus on the submission and marks it pending (paid later through `createInfluencerPayout`), and is rejected when the board's bonus is $0; `"organic"` is for content only on their own channels, and earns no payout. Re-approving an approved submission is rejected, except upgrading an `organic` approval to `ad`.
|
|
71
73
|
|
|
72
74
|
### Paying the bonus
|
|
73
75
|
|
|
74
|
-
`createInfluencerPayout` charges the organization's card and starts the creator's bonus on its way. **Never call it on your own initiative
|
|
76
|
+
`createInfluencerPayout` with `{ "eventId": "..." }` charges the organization's card and starts the creator's bonus on its way. **Never call it on your own initiative**: every call needs the client's explicit, fresh approval to pay this specific creator; a standing instruction doesn't count. The endpoint enforces its own preconditions (a submission approved with `approvalType: "ad"`, no payout already active for the visit: one per visit). The amount defaults to the bonus stamped on the submission when it was approved (falling back to the board config), grossed up to cover the Stripe fee; pass `amountCents` only when the client explicitly asks to pay this one creator a different amount. It applies to this payout only, is written back to the submission so reporting matches what was paid, and leaves the board config unchanged. A visit whose only attempts are FAILED or REFUNDED may be retried, which voids the earlier attempt's open invoice first. After the charge, Stripe webhooks carry it to the creator with no further action from you. Follow progress in `queryData` `creators.creatorPayout`, joined to the visit on `visitEventId`.
|
|
77
|
+
|
|
78
|
+
### Reimbursing boards
|
|
79
|
+
|
|
80
|
+
On a board with `reimbursementEnabled`, the creator pays for the meal and uploads a receipt with their submission, and the client pays them back by their own means (up to the `foodCreditAmountCents` cap). `markReimbursementPaid` with `{ "submissionId": "...", "reimbursementPaidNote": "..." }` **moves no money**: it only records that the client already sent it. **Call it only after the client tells you the money has gone out.** The submission must be approved with its reimbursement pending; a submission with no receipt was never on a reimbursing board and is rejected. Read the receipt total (`receiptTotalCents`) and `reimbursementStatus` off the `listCreatorSubmissions` row before recording anything.
|
|
75
81
|
|
|
76
82
|
### Conversations
|
|
77
83
|
|
|
78
|
-
`listCreatorConversations` is the "who is waiting on a reply" queue: every creator's SMS thread with `hasUnread
|
|
84
|
+
`listCreatorConversations` is the "who is waiting on a reply" queue: every creator's SMS thread with `hasUnread` (their last message came in after ours and nobody has marked it read, so a human needs to answer), the last message body, time and direction, the creator's handles, `visitLocationIds` (every location they have a visit at, in any status), and a derived `visitStatus` chip that's more reliable than reading raw columns.
|
|
79
85
|
|
|
80
|
-
`getCreatorConversation` with a row's `userId` loads the full thread behind it, newest first. Read it before characterizing an exchange or drafting a reply
|
|
86
|
+
`getCreatorConversation` with a row's `userId` loads the full thread behind it, newest first: each message's body, direction and timestamps. It comes back empty when the creator has no phone number on file. Read it before characterizing an exchange or drafting a reply; the queue's last-message snippet is not enough context to speak for a whole conversation.
|
|
81
87
|
|
|
82
|
-
**
|
|
88
|
+
**Replying is `sendText` with `{ "to": { "type": "creator", "userId": "..." }, "message": "..." }`.** It is a real SMS, sent immediately, with no undo and no scheduling. **Show the user the exact text and get their go-ahead before sending**; drafting is yours, sending is theirs to approve. Use `type: "creator"` for a creator thread even if the person also holds a guest pass. One recipient per call; there is no bulk form. Creator messages are sent verbatim (no handlebars). Sending also dismisses any reply the AI agent has staged for that creator and re-runs the agent with your message in context, so it doesn't talk over you. Marking a conversation read is the one thing that stays in the dashboard.
|
|
83
89
|
|
|
84
90
|
### Everything else: queryData
|
|
85
91
|
|
|
86
|
-
The `creators` schema exposes `creator` (the person, one row shared across all their applications), `creatorVisitApplication` (one application/visit), and `creatorPayout` (one initiated bonus payout, joined to the visit on `visitEventId`). Join person to visit on `creator.influencerId = creatorVisitApplication.userId`. Use it for anything the tools above don't answer
|
|
87
|
-
|
|
88
|
-
> **Not exposed:** replying to a creator's texts or marking a conversation read (the dashboard owns creator messaging), the dashboard's launch-program button itself (its bookkeeping rides the recruitment publish effect — see above), and publishing a creator's submitted content as a partnership ad.
|
|
92
|
+
The `creators` schema exposes `creator` (the person, one row shared across all their applications), `creatorVisitApplication` (one application/visit), and `creatorPayout` (one initiated bonus payout, joined to the visit on `visitEventId`). Join person to visit on `creator.influencerId = creatorVisitApplication.userId`. Use it for anything the tools above don't answer: no-shows, per-location counts, repeat creators, payout history. Content submissions are **not** in the catalog; `listCreatorSubmissions` is the only read.
|
|
89
93
|
|
|
90
|
-
|
|
94
|
+
> **Not exposed:** marking a creator conversation read, assigning a brief to a visit, the dashboard's launch-program button itself (its bookkeeping rides the recruitment publish effect, see above), and publishing a creator's submitted content as a partnership ad.
|
|
@@ -1,34 +1,30 @@
|
|
|
1
1
|
# Funnels
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
**Applying a funnel template** expands a whole screen tree server-side in one call: `applyFunnelTemplate` (needs the campaign's funnel unset — a fresh campaign — and resolves the referrer from the campaign). `deleteFunnel` tears one down.
|
|
3
|
+
**Applying a funnel template** expands a whole screen tree server-side in one call: `applyFunnelTemplate` (needs the campaign's funnel unset, as on a fresh campaign, and resolves the referrer from the campaign). `deleteFunnel` with `{ "campaignId": "..." }` tears one down: it deletes the campaign's own screens and resets its overrides, returning the campaign to the choose-template state.
|
|
6
4
|
|
|
7
5
|
**Individual funnel screens are edited** through a **draft → preview → promote** loop. You never apply edits locally: you stage them on an off-prod draft, preview the result at a stable URL, then save. Tools: `listFunnelScreens`, `createFunnelDraft`, `stageFunnelEdit`, `stageFunnelScreen`, `getFunnelDraft`, `listFunnelDrafts`, `discardFunnelDraft`, `saveFunnelEdits`.
|
|
8
6
|
|
|
9
7
|
### The loop
|
|
10
8
|
|
|
11
|
-
1. **`listFunnelScreens`** `{ "referrer": "<subdomain>", "campaignId": "<id>" }
|
|
12
|
-
2. **`createFunnelDraft`** `{ "referrer": "...", "campaignId": "..." }
|
|
13
|
-
3. **`stageFunnelEdit`** `{ "draftId": "...", "screenId": "...", "edit": <RenderableEdit> }
|
|
14
|
-
- `{ "type": "update", "id": "<renderableId>", "renderable": { ...clone of what you read, with your changes... } }
|
|
15
|
-
- `{ "type": "create", "renderable": { "id": "<new-uuid>", ... }, "targetId": "<sibling id>", "position": "before" | "after" | "inside" }
|
|
9
|
+
1. **`listFunnelScreens`** `{ "referrer": "<subdomain>", "campaignId": "<id>" }`: read the funnel's screens to get each `screenId` and its renderables' `id`s + content. **Read before any `update` edit**: an update replaces a renderable by id, so you need its current shape. (Omit `campaignId` for a base/members-program funnel.)
|
|
10
|
+
2. **`createFunnelDraft`** `{ "referrer": "...", "campaignId": "..." }`: creates an off-prod overlay; keep the returned `draftId` and **reuse it for the rest of the conversation**. One draft holds as many edits as you need, so don't open a second one per change. Start a fresh draft after a promote (which seals the old one) or when you're abandoning what you staged, and `discardFunnelDraft` the one you're leaving. Don't adopt a draft you didn't create here: an open one may hold edits someone else staged, and promoting it would ship them. If the user asks you to pick up earlier work, `listFunnelDrafts` shows what's open (filter with `"status": "open"`); confirm which one with them before staging onto it. Nothing is live until the save. **Immediately inspect the funnel's current state**: open the draft's preview URL (see step 4; with no edits staged it renders the live funnel as-is) so you have a visual baseline of what you're about to change. If you have a browser/screenshot tool, open and screenshot it first; if you can't view it yourself, share the URL with the user before editing.
|
|
11
|
+
3. **`stageFunnelEdit`** `{ "draftId": "...", "screenId": "...", "edit": <RenderableEdit> }`: one renderable edit per call; the server validates it against the current screen. Repeat per change. The `edit` is the named type `RenderableEdit`, a discriminated union (the renderables inside it are the named types `Renderable` and `RootRenderable`):
|
|
12
|
+
- `{ "type": "update", "id": "<renderableId>", "renderable": { ...clone of what you read, with your changes... } }`: keep the same `id`.
|
|
13
|
+
- `{ "type": "create", "renderable": { "id": "<new-uuid>", ... }, "targetId": "<sibling id>", "position": "before" | "after" | "inside" }`: generate a fresh UUID.
|
|
16
14
|
- `{ "type": "delete", "id": "<renderableId>" }` and `{ "type": "move", "id": "...", "targetId": "...", "position": "..." }`.
|
|
17
|
-
**Need a brand-new screen?** `stageFunnelScreen` `{ "draftId": "...", "title": "...", "description"? }` stages an empty screen on the draft and returns its **permanent `screenId
|
|
18
|
-
4. **Preview
|
|
19
|
-
- `previewFunnelDraft` renders every screen to a **PDF** (one screen per page, mobile viewport) and returns a short-lived download URL
|
|
20
|
-
- The live preview page `https://{referrer}.feastalytics.com/preview/{draftId}/{campaignId}` (drop `/{campaignId}` for a members-program draft) renders the funnel as a flow diagram with the edits applied
|
|
21
|
-
Iterate: re-run `listFunnelScreens` **with the `draftId`** to read the funnel *with* the staged edits, stage more, re-preview
|
|
22
|
-
5. **`saveFunnelEdits`** `{ "draftId": "..." }
|
|
23
|
-
|
|
24
|
-
**Promote once per piece of work, not once per change.** `stageFunnelEdit` rejects a draft that is not `open`, so a promote ends that draft and the next edit needs a new one. A user asking for four tweaks in a row wants four tweaks, not four promotes — stage them together and promote when the funnel is where they asked for it, or when they say to publish.
|
|
15
|
+
**Need a brand-new screen?** `stageFunnelScreen` `{ "draftId": "...", "title": "...", "description"? }` stages an empty screen on the draft and returns its **permanent `screenId`**. The id is assigned at staging time, not at save, so you can immediately `stageFunnelEdit` content into it and reference it from navigation/buttons on other screens; nothing gets re-keyed at promote. On a campaign draft the screen is campaign-scoped unless you pass `"campaignScoped": false`. The screen only exists on the draft (and in draft-scoped `listFunnelScreens`/previews) until `saveFunnelEdits` promotes it, which reports it in `createdScreenIds`.
|
|
16
|
+
4. **Preview**, two ways; use whichever fits how you can look at things:
|
|
17
|
+
- `previewFunnelDraft` renders every screen to a **PDF** (one screen per page, mobile viewport) and returns a short-lived download URL. This is the option that works when you can read files but not browse.
|
|
18
|
+
- The live preview page `https://{referrer}.feastalytics.com/preview/{draftId}/{campaignId}` (drop `/{campaignId}` for a members-program draft) renders the funnel as a flow diagram with the edits applied. This is the link to hand the user.
|
|
19
|
+
Iterate: re-run `listFunnelScreens` **with the `draftId`** to read the funnel *with* the staged edits, stage more, re-preview, until it's right.
|
|
20
|
+
5. **`saveFunnelEdits`** `{ "draftId": "..." }`, its only input. It **promotes to prod, with no confirmation prompt**: applies the draft's edits to the live funnel, creates any staged screens, and marks the draft `promoted`. To abandon instead, `discardFunnelDraft`.
|
|
25
21
|
|
|
26
|
-
`
|
|
22
|
+
**Promote once per piece of work, not once per change.** `stageFunnelEdit` rejects a draft that is not `open`, so a promote ends that draft and the next edit needs a new one. A user asking for four tweaks in a row wants four tweaks, not four promotes. Stage them together and promote when the funnel is where they asked for it, or when they say to publish. Every funnel edit goes through a draft; there is no one-shot save.
|
|
27
23
|
|
|
28
24
|
### Domain rules
|
|
29
|
-
- **Base screens vs campaign screens.** A campaign-owned screen is edited in place; a **base/shared screen edited in a campaign context becomes a campaign *override
|
|
25
|
+
- **Base screens vs campaign screens.** A campaign-owned screen is edited in place; a **base/shared screen edited in a campaign context becomes a campaign *override***, and the shared screen is left untouched. `saveFunnelEdits` decides this automatically from the draft's `campaignId`, so a change scoped to one campaign only affects that campaign.
|
|
30
26
|
- **Read before every `update`.** Construct the edit from what `listFunnelScreens` returned (renderable ids are stable), never from memory.
|
|
31
27
|
- **One edit per `stageFunnelEdit`**, staged incrementally; each is validated as it lands.
|
|
32
|
-
- **Drift guard.** `saveFunnelEdits` rejects the promote if the live funnel changed since the draft was created
|
|
28
|
+
- **Drift guard.** `saveFunnelEdits` rejects the promote if the live funnel changed since the draft was created; re-create the draft in that case. Do not lean on it to catch someone else editing the same funnel; keeping to one open draft (step 2) is the real protection.
|
|
33
29
|
|
|
34
30
|
---
|
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
# Guests and members
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`searchUsers` returns a page of recent member activity: one event per member, each carrying the member's `serialNumber` plus the event (type, time, related object).
|
|
4
4
|
|
|
5
|
-
`
|
|
6
|
-
|
|
7
|
-
- Filter with `query` (free-text name), `eventTypes` (e.g. `sentText`, `receivedText`, `scan`, `order`, `rewardAwarded`, `rewardRedeemed`, `checkout`, the `*Attribution` types), `progressMinBound`/`progressMaxBound` (visit-count range), `isUnread: true` (members with unanswered inbound texts), `orderBy` (ASC|DESC by event time).
|
|
5
|
+
- Filter with `query` (free-text name), `eventTypes` (e.g. `sentText`, `receivedText`, `scan`, `order`, `rewardAwarded`, `rewardRedeemed`, `checkout`, the `*Attribution` types), `campaignId`, `progressMinBound`/`progressMaxBound` (visit-count range), `isUnread: true` (members with unanswered inbound texts; it overrides any broader `eventTypes`), `orderBy` (ASC|DESC by event time).
|
|
8
6
|
- Paginate with `limit` (default 100) and `cursor` (pass back the `cursor` from the previous call; an undefined cursor means no more pages).
|
|
9
7
|
|
|
10
|
-
`getMemberConversation` with a member's `serialNumber` loads their thread, newest first
|
|
8
|
+
`getMemberConversation` with a member's `serialNumber` loads their thread, newest first: the pair to `searchUsers` the same way `getCreatorConversation` pairs with `listCreatorConversations`. Always pass `eventTypes`: `["sentText","receivedText"]` is the SMS thread, and adding `scan`/`order`/`checkout`/`rewardAwarded`/`rewardRedeemed` interleaves what happened between the messages. Unfiltered it returns the member's entire history unpaginated.
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
## Replying to a guest
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
`sendText` sends one SMS from the organization's texting number, the reply you would otherwise type into the dashboard chat. It is high priority and sent immediately: **no scheduling, no undo, no bulk form**.
|
|
13
|
+
|
|
14
|
+
1. Find the guest with `searchUsers` (`isUnread: true` is the "waiting on a reply" queue) and read the thread with `getMemberConversation` before drafting anything.
|
|
15
|
+
2. **Show the user the exact text and get their go-ahead before sending.** Drafting is yours; sending is theirs to approve, every time.
|
|
16
|
+
3. `sendText` with `{ "to": { "type": "guest", "serialNumber": "..." }, "message": "..." }`. Guest messages render `{{firstName}}`-style handlebars (the same ones text automations use) before sending. `mediaUrls` attaches up to 10 images.
|
|
17
|
+
4. **One recipient per call.** To reach several guests, call once per guest, each with its own confirmed text; for a broadcast, use a text blast automation instead.
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
The recipient is always named by id, never by phone number, and the type must match the thread: a guest who is also a creator exists in both tables, so use `type: "guest"` for a member thread and `type: "creator"` for a creator thread (see `creators.md`). A guest who doesn't belong to the organization is a 404, not a text to a stranger.
|
|
20
|
+
|
|
21
|
+
**Unknown senders.** Someone who texted the organization's number without being a member or a creator is answered with `{ "type": "unknownSender", "phoneNumber": "+1..." }`. It is the only form that takes a raw number, and it is refused unless that number has an inbound message to the organization on file, so it can only answer, never cold-text.
|
|
22
|
+
|
|
23
|
+
## Everything else: the data catalog
|
|
17
24
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
25
|
+
`searchUsers` answers "recent activity, one event per member." Every other read question about guests (and about orders, menu items, texts, reservations, creator visits, payouts) goes through **`describeData` → `queryData`**:
|
|
26
|
+
|
|
27
|
+
- `describeData` with no arguments returns the index of every queryable object type plus the full query grammar; narrowed by schema or object type it returns full column detail (type, enum values, nullability, description, and the link names `pivot` and `join` take). Pass `includeGrammar: false` once you have the grammar. Never guess column names.
|
|
28
|
+
- `queryData` is read-only and always scoped to the organization; never filter on organizationId yourself.
|
|
29
|
+
- Writing a query: `commands` run in order (`filter`, `pivot`, `join`, `aggregate`), and `pivot` and `join` must come before any `aggregate`. A filter leaf is one column, written as the column name prefixed with `$`; combine leaves with `{ "type": "and" | "or", "filters": [...] }`. Use `{ "strings": [...] }` for any-of rather than a large `or`. Pass `args.fields` to return only the columns you need on wide object types, and page by passing the returned `nextCursor` back as `args.cursor` (no `nextCursor` means no more rows).
|
|
30
|
+
- Example, opted-in members with more than 5 visits, newest first:
|
|
31
|
+
```json
|
|
32
|
+
{ "schemaName": "core", "objectTypeName": "guest",
|
|
33
|
+
"commands": [{ "type": "filter", "filter": { "type": "and", "filters": [{ "$optIn": { "boolean": true } }, { "$progress": { "number": 5, "match": "GT" } }] } }],
|
|
34
|
+
"args": { "limit": 500, "order": { "field": "timeAdded", "direction": "DESC" }, "fields": ["serialNumber", "phoneNumber", "progress"] } }
|
|
35
|
+
```
|
|
36
|
+
- Six schemas: `interface` (POS-agnostic orders, order items, menu `catalogItem`s, `location`s, reservations: the same shape whichever POS the org runs), `core` (guests/members), `events` (user events), `texting` (SMS logs), `creators` (visits and payouts), `attribution` (campaign attribution). Prefer `interface` for anything POS-shaped.
|
|
21
37
|
|
|
22
38
|
Typical uses: visit counts and cohorts, order history for one guest, menu items with real prices for grounding copy, text delivery history, creator payout status.
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
# Members program and wallet pass
|
|
2
2
|
|
|
3
|
-
> Part of the Feastalytics CLI workflows. Confirm a tool exists with `feast tools` before relying on it, and get its exact fields from `feast describe <tool>` — this file gives the *meaning* and *ordering* the schema can't.
|
|
4
|
-
|
|
5
|
-
|
|
6
3
|
## Members program (retention)
|
|
7
4
|
|
|
8
|
-
The retention counterpart to campaigns
|
|
5
|
+
The retention counterpart to campaigns: flows with no `campaignId`.
|
|
9
6
|
|
|
10
7
|
- **Automations are authorable** (see the automations workflow): use `listAutomationFlows` with `{ "scope": "membersProgram" }`, then the same create/edit loop. Remember the members-program 30-day `awardReward` default.
|
|
11
|
-
- **Rewards are fully manageable.** `listMembersProgramRewards` returns every reward with its catalog item's `name`, `staffInstructions`, `pointsCost` and `source
|
|
12
|
-
- **Creating takes one of two shapes.** `{ "type": "item", "itemId" }` promotes an existing catalog item
|
|
13
|
-
- **The `pointsCost` fork matters.** A reward with `pointsCost` set is redeemed *by the member with points* and is never auto-awarded. Omit `pointsCost` for automation-awarded rewards
|
|
14
|
-
- `updateMembersProgramReward` corrects a reward in place
|
|
8
|
+
- **Rewards are fully manageable.** `listMembersProgramRewards` returns every reward with its catalog item's `name`, `staffInstructions`, `pointsCost` and `source`. The item is resolved across the Feast, Toast, Square and Clover catalogs, and a `null` name means it doesn't exist in any of them.
|
|
9
|
+
- **Creating takes one of two shapes.** `{ "type": "item", "itemId" }` promotes an existing catalog item; prefer it whenever the item already exists in the POS. `{ "type": "name", "name" }` looks the name up across all four catalogs and creates a new Feast item only if nothing matches; **the match is exact, so a near-miss silently duplicates a menu item the restaurant already has**. Check `listMembersProgramRewards` or the catalog first. When several items share the name, a Feast item among them wins; otherwise you get a CONFLICT listing the candidates so you can pass `itemId` instead. `staffInstructions` only exist on Feast items and are rejected for POS-sourced ones.
|
|
10
|
+
- **The `pointsCost` fork matters.** A reward with `pointsCost` set is redeemed *by the member with points* and is never auto-awarded. Omit `pointsCost` for automation-awarded rewards, and then actually pair the reward with an `awardReward` automation (see the automations workflow), or it will never reach anyone. To find orphans, cross-reference `listAutomations` for `awardReward` actions carrying the reward's `itemId`.
|
|
11
|
+
- `updateMembersProgramReward` corrects a reward in place; `pointsCost: null` converts a points reward into an automation-granted one. `deleteMembersProgramReward` is the orphan cleanup; it leaves the catalog item alone (it may be a real menu item) and doesn't claw back anything already redeemed.
|
|
12
|
+
|
|
13
|
+
### Giving one member a reward
|
|
14
|
+
|
|
15
|
+
`awardReward` grants a reward to a single member right now, like the dashboard's Give Reward button. It is **not** `createMembersProgramReward`: that defines a reward the program offers, this puts one into a specific guest's wallet pass.
|
|
16
|
+
|
|
17
|
+
- Input: `{ "serialNumber": "...", "itemId": "...", "expiresInDays": 14 }`. Get `serialNumber` from `searchUsers` and `itemId` from `listMembersProgramRewards` (or a catalog query). Both are checked against the organization, and a wrong id is rejected rather than granted.
|
|
18
|
+
- Expiry is optional, and a reward with none never expires. `expiresInDays` ends at the end of that day in the restaurant's timezone (what a guest reads "14 days" to mean); `expiresAt` takes an exact ISO 8601 instant. Pass one or the other. `locationId` restricts redemption to one participating location.
|
|
19
|
+
- It recomputes the member's progress, which **re-evaluates their automations**, so a flow triggered by earning a reward will fire (and may text them).
|
|
20
|
+
- **No undo and no idempotency key: a retried call grants a second reward.** Confirm the member, item and expiry with the user before calling, call once per member, and if a call's outcome is unclear, check the member's `rewardAwarded` events with `getMemberConversation` before retrying.
|
|
15
21
|
|
|
16
22
|
---
|
|
17
23
|
|
|
@@ -19,8 +25,6 @@ The retention counterpart to campaigns — flows with no `campaignId`.
|
|
|
19
25
|
|
|
20
26
|
The pass (the wallet membership card) is read and written as a whole document.
|
|
21
27
|
|
|
22
|
-
- **`getPassConfiguration`** `{}
|
|
23
|
-
- **`updatePassConfiguration
|
|
24
|
-
- Pass **image generation** (punch-card strips etc.) is not exposed
|
|
25
|
-
|
|
26
|
-
---
|
|
28
|
+
- **`getPassConfiguration`** `{}`: returns the latest live configuration (`sections`, `features`, `locations`, `metadata`, and its `version`), or `null` when none has been saved.
|
|
29
|
+
- **`updatePassConfiguration`**: **a full-document save, not a patch.** Anything you omit is dropped from the new version. The only safe workflow is read → modify the returned document → save the complete result. `sections` is a `PassSections` and `features` a `PassFeatures`. Saving appends a new version (history is preserved server-side), and a change to sections, features, locations or `passStyle` re-pushes the pass to every member's wallet. `passStyle` is the Apple pass style every one of the organization's passes is built with (`eventTicket`, `storeCard`, `generic`, `coupon`); unset means `eventTicket`. There is no confirmation prompt, so treat it with the same care as a live send. Omit `passStyle` to keep the current style; `null` clears it back to `eventTicket`.
|
|
30
|
+
- Pass **image generation** (punch-card strips etc.) is not exposed; image workflows go through the app.
|
|
@@ -1,34 +1,33 @@
|
|
|
1
1
|
# Onboarding and brand
|
|
2
2
|
|
|
3
|
-
> Part of the Feastalytics CLI workflows. Confirm a tool exists with `feast tools` before relying on it, and get its exact fields from `feast describe <tool>` — this file gives the *meaning* and *ordering* the schema can't.
|
|
4
|
-
|
|
5
|
-
|
|
6
3
|
## Onboarding tasks (the taskboard)
|
|
7
4
|
|
|
8
|
-
`getTaskboard` is the single "what needs fixing or finishing" surface: one `entries` list discriminated by `kind`. `task` entries are the org's onboarding tasks; `issue` entries are live-computed misconfigurations with a `fixHint`. Scope with `{"scope":{"type":"onboarding"}}` for tasks only, `{"type":"task","task":{"taskId":"..."}}` for one task, or leave the default `all`.
|
|
5
|
+
`getTaskboard` is the single "what needs fixing or finishing" surface: one `entries` list discriminated by `kind`. `task` entries are the org's onboarding tasks; `issue` entries are live-computed misconfigurations (placeholder content, inactive automations, a missing "Text STOP" opt-out, unawarded rewards, wallet pass and pixel problems), each with a `severity`, a human `message` and a `fixHint`. Funnel checks cover only screens reachable from the funnel's start screen, so an orphaned screen raises no issue. Scope with `{"scope":{"type":"onboarding"}}` for tasks only, `{"type":"task","task":{"taskId":"..."}}` for one task, or leave the default `all`.
|
|
9
6
|
|
|
10
7
|
- **Start from `completionInstructions`, not guesswork.** Every task entry says exactly what completes it and whether it needs a human in a browser. Trust it over inferring from the task name.
|
|
11
|
-
- **Split the work accordingly.** Campaigns, automations, funnel fixes, rewards, brand identity, the phone number, image uploads and the onboarding form are all completable through
|
|
12
|
-
- **Never claim a task complete or try to mark one.** Statuses are derived from live data by a recompute (triggered by every taskboard read, ~30s lag)
|
|
13
|
-
- Working through onboarding = repeat: `getTaskboard` (scope `onboarding`) → do the
|
|
8
|
+
- **Split the work accordingly.** Campaigns, automations, funnel fixes, rewards, brand identity, the phone number, image uploads and the onboarding form are all completable through the tools, so do them. Tasks that need OAuth (Facebook, POS), physical device setup, or in-restaurant staff training cannot be: hand the user that task's **`completionUrl`**, a page where they complete exactly that task. Paste the URL directly in your reply so the user can open it.
|
|
9
|
+
- **Never claim a task complete or try to mark one.** Statuses are derived from live data by a recompute (triggered by every taskboard read, ~30s lag). Do the underlying work, then re-read the taskboard to confirm the checkmark flipped.
|
|
10
|
+
- Working through onboarding = repeat: `getTaskboard` (scope `onboarding`) → do the tool-doable incomplete required tasks → hand over completionUrls for the rest → re-read to verify.
|
|
14
11
|
|
|
15
12
|
## The onboarding form
|
|
16
13
|
|
|
17
|
-
Some tasks read self-reported answers rather than observed data
|
|
14
|
+
Some tasks read self-reported answers rather than observed data: launch date, funnel direction, per-step `isComplete` markers. `getOnboardingForm` reads them (`null` when the org has no form yet); `updateOnboardingForm` writes them (and creates the form when there is none). Top-level keys you omit are left alone. **Nested step objects are replaced, not merged**: read first and send back the whole step you're editing (`data` is the exception and is merged). Setting `pos.details.type` to `"other"` provisions a manual-entry POS location as a side effect.
|
|
15
|
+
|
|
16
|
+
Two POS setup tasks complete off `updateOrganization` instead: `staffInstructions.scan` completes *Members Program Visits POS setup*, and `.prepaid` is additionally required for *Campaign POS setup* when the promotion allows pre-pay. `staffInstructions` is replaced wholesale, so send every key you want to keep.
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
Other `updateOrganization` fields: `timezone` is an IANA zone (`America/New_York`); `minimumSpendValue` is in dollars. `periodCalendar` sets how Impact and revenue plans group weeks into periods: `{"type":"fiscal","fiscal":{"pattern":"4-4-5","yearEndWeekday":"sunday","yearEndRule":"nearestDec31"}}`, where `pattern` is `4-4-5`, `4-5-4`, `5-4-4` or `13x4`, `yearEndWeekday` is `sunday` through `saturday`, and `yearEndRule` is `nearestDec31` or `lastInDecember`. `null` resets it to the default: 13 four-week periods ending on the Sunday nearest Dec 31. `restaurantType`, `isArchived` and `isReadOnly` are admin-only and rejected for anyone else.
|
|
20
19
|
|
|
21
20
|
## Establishing brand identity
|
|
22
21
|
|
|
23
|
-
1. `searchGooglePlaces
|
|
24
|
-
2. `createBrandIdentity
|
|
25
|
-
3. `updateBrandIdentity
|
|
22
|
+
1. `searchGooglePlaces`: resolve the restaurant to its Google Place. `{"type":"search","query":"Todays Pizza, Brooklyn NY"}` returns ranked candidates; include the city, since a bare name is usually ambiguous. Pass `referrer` to bias the search toward that site's saved coordinates. `confidentMatch` is non-null only when one candidate is unambiguous: its website domain matches the site's, or it is the only candidate whose name matches the query. Otherwise show the candidates and let the customer pick. `{"type":"lookup","placeId":"..."}` returns that one place (name, address, website, coordinates), useful for inspecting a place already stored on a site.
|
|
23
|
+
2. `createBrandIdentity`: the branded site: a subdomain, layout config, and a full default screen tree. The subdomain is claimed **across all organizations** and gates everything funnel-shaped downstream, so confirm the name with the customer first. `referrer` is the subdomain label only (letters and numbers, no dots) and is lowercased; a label another restaurant uses is rejected. Only an admin can create a second site for an organization that already has one. Get `logoUrl` via `getMediaUploadUrl`.
|
|
24
|
+
3. `updateBrandIdentity`: business data, theme, tracking pixel IDs, OpenTable links, and the Google Place link. It deep-merges, so send only what you're changing. **Setting `googleConfig.placeId` enqueues a review/photo scrape; changing an existing placeId orphans everything scraped under the old one.** Confirm before replacing. A new placeId on a site whose `googleConfig` has coordinates also buys and bills the nearest texting number when the organization has none yet (an onboarding form exists, its phone step is incomplete, and it is not a test organization). Changing `hostname` enqueues a DNS update.
|
|
26
25
|
|
|
27
|
-
Browser-only: the brand *import* intelligence
|
|
26
|
+
Browser-only: the brand *import* intelligence (auto-extracting a usable palette and logo from a scraped site) lives in the app, not the tools. If the customer wants that flow, hand them the dashboard.
|
|
28
27
|
|
|
29
28
|
## Plumbing the taskboard leans on
|
|
30
29
|
|
|
31
|
-
- **Phone number
|
|
32
|
-
- **Media
|
|
33
|
-
- **Team
|
|
34
|
-
- **Billing
|
|
30
|
+
- **Phone number**: `searchAvailablePhoneNumbers` (free; search by the restaurant's own postal code or coordinates, since proximity beats a memorable area code) then `purchaseAndConfigurePhoneNumber`, which **bills the account irreversibly**. Establish where the restaurant actually is before buying.
|
|
31
|
+
- **Media**: `getMediaUploadUrl` (PUT the bytes to the presigned URL, then reference the returned key), `listMedia`, `deleteMedia`. This is how logos and offer images get in through the tools.
|
|
32
|
+
- **Team**: `inviteUser` sends a real email immediately and **defaults to OWNER** (full billing access), so always pass `role` explicitly; VIEWER is read-only, SCANNER is for staff running the scanner app. A new person gets an invitation valid for 14 days; someone with a Feast account gets a login reminder and is added right away. Re-inviting an email cancels its pending invites and sends a fresh one. Only an OWNER can invite.
|
|
33
|
+
- **Billing**: `getBillingStatus`, read-only: `hasAccess` answers "can they use the product," `needsPayment` flags the states worth acting on and is what the dashboard reads to put the app behind a payment form. `currentTier` and `subscriptionStatus` describe the plan. `existingOrganizations` lists every organization billed under the same billing admin's subscription (this one included when it is on that subscription), with names and tiers; it is absent for per-organization billing. Every billing write stays in the dashboard.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feastalytics/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"description": "Command-line client for the Feastalytics platform — list, create, and update campaigns, automations, offers, and members-program rewards from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|