@localpulse/cli 0.0.3 → 0.0.5

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 CHANGED
@@ -10,15 +10,15 @@ CLI for [Local Pulse](https://localpulse.nl) — ingest event posters, search ev
10
10
  bunx @localpulse/cli --help
11
11
  ```
12
12
 
13
- **Standalone binary:**
14
-
15
- Download the latest binary for your platform from the [releases page](https://github.com/localpulse/local_pulse/releases), then:
13
+ **Global install:**
16
14
 
17
15
  ```sh
18
- chmod +x localpulse
19
- ./localpulse --help
16
+ bun add -g @localpulse/cli
17
+ localpulse --help
20
18
  ```
21
19
 
20
+ See the package on [npm](https://www.npmjs.com/package/@localpulse/cli).
21
+
22
22
  ## Quick start
23
23
 
24
24
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@localpulse/cli",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Local Pulse CLI — ingest event posters, search events, manage credentials",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.test.ts CHANGED
@@ -53,7 +53,7 @@ describe("localpulse", () => {
53
53
  expect(stdout).toContain("--dry-run");
54
54
  expect(stdout).toContain("--json");
55
55
  expect(stdout).toContain("Examples:");
56
- expect(stdout).not.toContain("--performers");
56
+ expect(stdout).not.toContain("--featured");
57
57
  expect(stdout).not.toContain("--genre");
58
58
  expect(stdout).not.toContain("--socials");
59
59
  expect(stdout).not.toContain("--organizer");
@@ -127,7 +127,7 @@ describe("localpulse", () => {
127
127
  expect(exitCode).toBe(0);
128
128
  const skeleton = JSON.parse(stdout);
129
129
  expect(() => validateResearchPayload(skeleton)).not.toThrow();
130
- expect(skeleton.performers).toBeDefined();
130
+ expect(skeleton.featured).toBeDefined();
131
131
  expect(skeleton.event).toBeDefined();
132
132
  expect(skeleton.venue).toBeDefined();
133
133
  expect(skeleton.organizer).toBeDefined();
@@ -136,8 +136,18 @@ describe("localpulse", () => {
136
136
  it("validates research payload from file with --dry-run", async () => {
137
137
  const researchFile = join(tmpdir(), `lp-test-${Date.now()}.json`);
138
138
  await writeFile(researchFile, JSON.stringify({
139
- performers: [{ name: "DJ Nobu", genre: "techno" }],
140
- event: { title: "Test Night" },
139
+ featured: [
140
+ { name: "DJ Nobu", type: "DJ", genre: "techno", socials: ["https://instagram.com/djnobu"], context: "Berlin-based" },
141
+ ],
142
+ organizer: { name: "Dekmantel", socials: ["https://instagram.com/dekmantel"] },
143
+ venue: { name: "Shelter", city: "Amsterdam", google_place_id: "ChIJ123" },
144
+ event: {
145
+ title: "Test Night",
146
+ date: "2026-03-14T22:00:00+01:00",
147
+ type: "club night",
148
+ urls: ["https://ra.co/events/123", "https://example.com/event"],
149
+ ticket_url: "https://tickets.example.com",
150
+ },
141
151
  }));
142
152
 
143
153
  const { exitCode, stdout } = runCli("ingest", "../../README.md", "--research", researchFile, "--dry-run");
@@ -145,6 +155,36 @@ describe("localpulse", () => {
145
155
  expect(stdout).toContain("Dry run passed");
146
156
  });
147
157
 
158
+ it("rejects thin research payload with audit findings", async () => {
159
+ const researchFile = join(tmpdir(), `lp-test-${Date.now()}.json`);
160
+ await writeFile(researchFile, JSON.stringify({
161
+ featured: [],
162
+ event: { title: "Test" },
163
+ }));
164
+
165
+ const { exitCode, stderr } = runCli("ingest", "../../README.md", "--research", researchFile, "--dry-run");
166
+ expect(exitCode).toBe(1);
167
+ expect(stderr).toContain("audit failed");
168
+ expect(stderr).toContain("featured");
169
+ expect(stderr).toContain("Fix these issues");
170
+ });
171
+
172
+ it("outputs structured audit findings with --json", async () => {
173
+ const researchFile = join(tmpdir(), `lp-test-${Date.now()}.json`);
174
+ await writeFile(researchFile, JSON.stringify({
175
+ event: { title: "Test" },
176
+ }));
177
+
178
+ const { exitCode, stdout } = runCli("ingest", "../../README.md", "--research", researchFile, "--json");
179
+ expect(exitCode).toBe(1);
180
+ const result = JSON.parse(stdout);
181
+ expect(result.audit_failed).toBe(true);
182
+ expect(result.findings).toBeArray();
183
+ expect(result.findings.length).toBeGreaterThan(0);
184
+ expect(result.findings[0].field).toBeDefined();
185
+ expect(result.findings[0].action).toBeDefined();
186
+ });
187
+
148
188
  it("rejects invalid research payload", async () => {
149
189
  const researchFile = join(tmpdir(), `lp-test-${Date.now()}.json`);
150
190
  await writeFile(researchFile, JSON.stringify({ performers: "not-an-array" }));
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  } from "./lib/credentials";
18
18
  import { loginWithToken } from "./lib/login";
19
19
  import { exitCodeForError, printError } from "./lib/output";
20
+ import { auditResearchPayload, formatAuditFindings } from "./lib/research-audit";
20
21
  import { readResearchPayload } from "./lib/research-reader";
21
22
  import {
22
23
  generateResearchSkeleton,
@@ -189,6 +190,19 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
189
190
  }
190
191
 
191
192
  const payload = await readResearchPayload(researchPath);
193
+ const jsonOutput = hasOption(parsed, "json");
194
+
195
+ const audit = auditResearchPayload(payload);
196
+ if (!audit.pass) {
197
+ if (jsonOutput) {
198
+ stdout.write(`${JSON.stringify({ audit_failed: true, findings: audit.findings })}\n`);
199
+ } else {
200
+ process.stderr.write(formatAuditFindings(audit.findings));
201
+ }
202
+ process.exitCode = 1;
203
+ return;
204
+ }
205
+
192
206
  const mapped = mapResearchToUploadFields(payload);
193
207
  const stitched = stitchResearchContext(payload);
194
208
  const explicitContext = readStringOption(parsed, "context");
@@ -197,7 +211,6 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
197
211
  const apiUrl = await resolveApiUrl();
198
212
  const dryRun = hasOption(parsed, "dry-run");
199
213
  const force = hasOption(parsed, "force");
200
- const jsonOutput = hasOption(parsed, "json");
201
214
  const token = dryRun ? "" : await requireToken();
202
215
 
203
216
  const uploadOptions = {
@@ -525,21 +538,46 @@ Research payload:
525
538
  The payload is JSON with five top-level sections. All are optional,
526
539
  but richer research produces much better event listings.
527
540
 
541
+ Research guidance:
542
+ The source URL is a starting point, not the finish line. After extracting
543
+ what the page provides, use Instagram graph traversal and web search to
544
+ fill gaps. Do NOT guess or fabricate profile URLs — verify every link.
545
+
546
+ Entity discovery (preferred order):
547
+ 1. Find the venue or organizer's Instagram profile first.
548
+ 2. Browse their recent posts or Reels for the event announcement.
549
+ 3. Check who is tagged in that post — these are verified performer handles.
550
+ 4. Also check the caption for @mentions, #hashtags, and links.
551
+ 5. Visit each tagged profile: their bio contains verified links (website,
552
+ Spotify, Bandcamp, YouTube) and genre/context info for featured[].context.
553
+ 6. Check the venue's Tagged tab for posts where others tag the venue —
554
+ artists promoting the event often tag the venue and each other.
555
+
556
+ Fallback research (when Instagram doesn't surface enough):
557
+ - Search "{name}" to find their website and background.
558
+ - Search Google Maps for "{venue} {city}" and extract the google_place_id.
559
+ - Search for the event on RA, Facebook, or the venue's own website.
560
+
561
+ Every person mentioned on the event page or tagged in the Instagram post
562
+ should appear in featured[] with a type that fits (chef, DJ, host…).
563
+
528
564
  Quality checklist (aim to fill as many as possible):
529
- Performer socials: Instagram, Spotify, Bandcamp, SoundCloud, RA, website
565
+ Featured person socials: Instagram (verified via tags), website, Spotify, Bandcamp, RA
566
+ ✓ Featured person context: pull from Instagram bio — genre, notable work, links
530
567
  ✓ Organizer socials: Instagram, website, RA promoter page
531
568
  ✓ Venue google_place_id (search Google Maps → share → extract place ID)
532
569
  ✓ Multiple event.urls: venue page, RA, Facebook event, artist page
533
570
  ✓ Embed URLs in context: Spotify album/track links, YouTube videos
534
- Performer context: recent releases, labels, residencies, tour info
535
- All support acts as separate performer entries
536
-
537
- performers[] Who is performing or presenting
538
- .name Full name (required per performer)
539
- .type What they are: "DJ", "band", "speaker", "visual artist"
540
- .genre Style or genre: "techno", "jazz", "stand-up comedy"
571
+ All featured people as separate entries (support acts, guest chefs, co-hosts)
572
+ Discover unlisted performers via Instagram tags (support acts, guest hosts)
573
+
574
+ featured[] Who is featured performers, chefs, hosts, speakers
575
+ .name Full name (required per person)
576
+ .type Their role: "DJ", "chef", "band", "host", "speaker",
577
+ "visual artist", "cook"
578
+ .genre Style, genre, or cuisine: "techno", "jazz", "Kerala"
541
579
  .socials[] Profile URLs: Instagram, RA, Bandcamp, personal site
542
- .context Anything else: bio, residencies, notable releases
580
+ .context Anything else: bio, notable work, affiliations
543
581
 
544
582
  organizer Who is putting on the event
545
583
  .name Collective, promoter, or brand name (required)
@@ -556,7 +594,8 @@ Research payload:
556
594
  .title Event name
557
595
  .date ISO 8601 datetime (e.g. "2026-03-14T22:00:00+01:00")
558
596
  .type Kind of event: "club night", "festival", "exhibition",
559
- "workshop", "concert", "open air", "listening session"
597
+ "workshop", "concert", "open air", "listening session",
598
+ "pop-up dinner", "food event", "tasting", "market"
560
599
  .price Ticket price info: "€15-25", "Free", "Sold out"
561
600
  .urls[] Source pages: RA, venue site, Facebook event
562
601
  .ticket_url Direct ticketing / purchase URL
@@ -0,0 +1,242 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { auditResearchPayload, formatAuditFindings } from "./research-audit";
4
+ import { generateResearchSkeleton } from "./research-schema";
5
+
6
+ describe("auditResearchPayload", () => {
7
+ it("passes a complete payload", () => {
8
+ const payload = {
9
+ ...generateResearchSkeleton(),
10
+ venue: {
11
+ name: "Shelter Amsterdam",
12
+ city: "Amsterdam",
13
+ google_place_id: "ChIJ123abc",
14
+ context: "Underground club beneath A'DAM Tower",
15
+ },
16
+ event: {
17
+ ...generateResearchSkeleton().event,
18
+ urls: [
19
+ "https://ra.co/events/1234567",
20
+ "https://www.dekmantel.com/events/shelter-nobu",
21
+ ],
22
+ },
23
+ };
24
+ const result = auditResearchPayload(payload);
25
+ expect(result.pass).toBe(true);
26
+ expect(result.findings).toHaveLength(0);
27
+ });
28
+
29
+ it("flags an empty payload with findings for every section", () => {
30
+ const result = auditResearchPayload({});
31
+ expect(result.pass).toBe(false);
32
+ const fields = result.findings.map((f) => f.field);
33
+ expect(fields).toContain("featured");
34
+ expect(fields).toContain("organizer");
35
+ expect(fields).toContain("venue");
36
+ expect(fields).toContain("event");
37
+ });
38
+
39
+ it("short-circuits: missing section emits one finding, not per-field", () => {
40
+ const result = auditResearchPayload({});
41
+ const venueFindings = result.findings.filter((f) => f.field.startsWith("venue"));
42
+ expect(venueFindings).toHaveLength(1);
43
+ expect(venueFindings[0].field).toBe("venue");
44
+ });
45
+
46
+ // --- featured ---
47
+
48
+ it("flags empty featured array", () => {
49
+ const result = auditResearchPayload({ featured: [] });
50
+ const f = result.findings.find((f) => f.field === "featured");
51
+ expect(f).toBeDefined();
52
+ expect(f!.action).toContain("featured[]");
53
+ });
54
+
55
+ it("flags featured person missing type, socials, and context", () => {
56
+ const result = auditResearchPayload({
57
+ featured: [{ name: "Sherin Kalam" }],
58
+ });
59
+ const fields = result.findings.map((f) => f.field);
60
+ expect(fields).toContain("featured[0].type");
61
+ expect(fields).toContain("featured[0].socials");
62
+ expect(fields).toContain("featured[0].context");
63
+ });
64
+
65
+ it("does not flag featured person with complete data", () => {
66
+ const result = auditResearchPayload({
67
+ featured: [
68
+ {
69
+ name: "DJ Nobu",
70
+ type: "DJ",
71
+ socials: ["https://instagram.com/djnobu"],
72
+ context: "Berlin-based Japanese DJ",
73
+ },
74
+ ],
75
+ });
76
+ const featuredFindings = result.findings.filter((f) => f.field.startsWith("featured"));
77
+ expect(featuredFindings).toHaveLength(0);
78
+ });
79
+
80
+ it("templates person name into action text", () => {
81
+ const result = auditResearchPayload({
82
+ featured: [{ name: "Sherin Kalam" }],
83
+ });
84
+ const socials = result.findings.find((f) => f.field === "featured[0].socials");
85
+ expect(socials!.action).toContain("Sherin Kalam");
86
+ });
87
+
88
+ // --- organizer ---
89
+
90
+ it("flags missing organizer", () => {
91
+ const result = auditResearchPayload({});
92
+ expect(result.findings.find((f) => f.field === "organizer")).toBeDefined();
93
+ });
94
+
95
+ it("flags organizer without socials", () => {
96
+ const result = auditResearchPayload({
97
+ organizer: { name: "Wanakam" },
98
+ });
99
+ const f = result.findings.find((f) => f.field === "organizer.socials");
100
+ expect(f).toBeDefined();
101
+ expect(f!.action).toContain("Wanakam");
102
+ });
103
+
104
+ it("does not flag organizer with socials", () => {
105
+ const result = auditResearchPayload({
106
+ organizer: {
107
+ name: "Wanakam",
108
+ socials: ["https://instagram.com/wanakam"],
109
+ },
110
+ });
111
+ const orgFindings = result.findings.filter((f) => f.field.startsWith("organizer"));
112
+ expect(orgFindings).toHaveLength(0);
113
+ });
114
+
115
+ // --- venue ---
116
+
117
+ it("flags venue without city", () => {
118
+ const result = auditResearchPayload({
119
+ venue: { name: "Shelter" },
120
+ });
121
+ expect(result.findings.find((f) => f.field === "venue.city")).toBeDefined();
122
+ });
123
+
124
+ it("flags venue without google_place_id", () => {
125
+ const result = auditResearchPayload({
126
+ venue: { name: "Shelter", city: "Amsterdam" },
127
+ });
128
+ const f = result.findings.find((f) => f.field === "venue.google_place_id");
129
+ expect(f).toBeDefined();
130
+ expect(f!.action).toContain("Shelter Amsterdam");
131
+ });
132
+
133
+ it("flags venue with empty-string google_place_id", () => {
134
+ const result = auditResearchPayload({
135
+ venue: { name: "Shelter", city: "Amsterdam", google_place_id: "" },
136
+ });
137
+ expect(result.findings.find((f) => f.field === "venue.google_place_id")).toBeDefined();
138
+ });
139
+
140
+ // --- event ---
141
+
142
+ it("flags event with no title", () => {
143
+ const result = auditResearchPayload({ event: {} });
144
+ expect(result.findings.find((f) => f.field === "event.title")).toBeDefined();
145
+ });
146
+
147
+ it("flags event with no date", () => {
148
+ const result = auditResearchPayload({ event: {} });
149
+ expect(result.findings.find((f) => f.field === "event.date")).toBeDefined();
150
+ });
151
+
152
+ it("flags event with no type", () => {
153
+ const result = auditResearchPayload({ event: {} });
154
+ expect(result.findings.find((f) => f.field === "event.type")).toBeDefined();
155
+ });
156
+
157
+ it("flags event with no urls", () => {
158
+ const result = auditResearchPayload({ event: {} });
159
+ expect(result.findings.find((f) => f.field === "event.urls")).toBeDefined();
160
+ });
161
+
162
+ it("flags event with only 1 url", () => {
163
+ const result = auditResearchPayload({
164
+ event: { urls: ["https://example.com"] },
165
+ });
166
+ const f = result.findings.find((f) => f.field === "event.urls");
167
+ expect(f).toBeDefined();
168
+ expect(f!.message).toContain("Only 1");
169
+ });
170
+
171
+ it("does not flag event with 2+ urls", () => {
172
+ const result = auditResearchPayload({
173
+ event: { urls: ["https://example.com", "https://ra.co/events/123"] },
174
+ });
175
+ expect(result.findings.find((f) => f.field === "event.urls")).toBeUndefined();
176
+ });
177
+
178
+ it("flags missing ticket_url when price is not free", () => {
179
+ const result = auditResearchPayload({
180
+ event: { price: "€15" },
181
+ });
182
+ expect(result.findings.find((f) => f.field === "event.ticket_url")).toBeDefined();
183
+ });
184
+
185
+ it("does not flag missing ticket_url when price is Free", () => {
186
+ const result = auditResearchPayload({
187
+ event: { price: "Free" },
188
+ });
189
+ expect(result.findings.find((f) => f.field === "event.ticket_url")).toBeUndefined();
190
+ });
191
+
192
+ it("does not flag missing ticket_url when price is free (case-insensitive)", () => {
193
+ const result = auditResearchPayload({
194
+ event: { price: "free" },
195
+ });
196
+ expect(result.findings.find((f) => f.field === "event.ticket_url")).toBeUndefined();
197
+ });
198
+
199
+ it("flags missing ticket_url when no price is set", () => {
200
+ const result = auditResearchPayload({
201
+ event: {},
202
+ });
203
+ expect(result.findings.find((f) => f.field === "event.ticket_url")).toBeDefined();
204
+ });
205
+ });
206
+
207
+ describe("formatAuditFindings", () => {
208
+ it("formats findings as human-readable text", () => {
209
+ const output = formatAuditFindings([
210
+ {
211
+ field: "featured",
212
+ message: "No featured people provided.",
213
+ action: "Add entries to featured[].",
214
+
215
+ },
216
+ {
217
+ field: "venue.google_place_id",
218
+ message: "No google_place_id for venue 'Shelter'.",
219
+ action: "Search Google Maps.",
220
+
221
+ },
222
+ ]);
223
+ expect(output).toContain("2 issues found");
224
+ expect(output).toContain("featured");
225
+ expect(output).toContain("No featured people provided.");
226
+ expect(output).toContain("-> Add entries to featured[].");
227
+ expect(output).toContain("venue.google_place_id");
228
+ expect(output).toContain("Fix these issues and retry.");
229
+ });
230
+
231
+ it("uses singular 'issue' for 1 finding", () => {
232
+ const output = formatAuditFindings([
233
+ {
234
+ field: "event.title",
235
+ message: "No event title.",
236
+ action: "Set event.title.",
237
+
238
+ },
239
+ ]);
240
+ expect(output).toContain("1 issue found");
241
+ });
242
+ });
@@ -0,0 +1,204 @@
1
+ import type { ResearchPayload } from "./research-schema";
2
+
3
+ export type AuditFinding = {
4
+ field: string;
5
+ message: string;
6
+ action: string;
7
+ };
8
+
9
+ export type AuditResult = {
10
+ pass: boolean;
11
+ findings: AuditFinding[];
12
+ };
13
+
14
+ export function auditResearchPayload(payload: ResearchPayload): AuditResult {
15
+ const findings = [
16
+ ...auditFeatured(payload),
17
+ ...auditOrganizer(payload),
18
+ ...auditVenue(payload),
19
+ ...auditEvent(payload),
20
+ ];
21
+
22
+ return { pass: findings.length === 0, findings };
23
+ }
24
+
25
+ function finding(field: string, message: string, action: string): AuditFinding {
26
+ return { field, message, action };
27
+ }
28
+
29
+ function auditFeatured(payload: ResearchPayload): AuditFinding[] {
30
+ if (!payload.featured?.length) {
31
+ return [
32
+ finding(
33
+ "featured",
34
+ "No featured people provided.",
35
+ "Read the event page for anyone mentioned (performers, chefs, hosts, speakers). Add each to featured[].",
36
+ ),
37
+ ];
38
+ }
39
+
40
+ const findings: AuditFinding[] = [];
41
+ for (let i = 0; i < payload.featured.length; i++) {
42
+ const person = payload.featured[i];
43
+ const label = `featured[${i}] ('${person.name}')`;
44
+
45
+ if (!person.type?.trim()) {
46
+ findings.push(
47
+ finding(
48
+ `featured[${i}].type`,
49
+ `${label} has no type.`,
50
+ `Set featured[${i}].type to their role (e.g., 'DJ', 'chef', 'host', 'speaker', 'visual artist').`,
51
+ ),
52
+ );
53
+ }
54
+
55
+ if (!person.socials?.length) {
56
+ findings.push(
57
+ finding(
58
+ `featured[${i}].socials`,
59
+ `${label} has no social profiles.`,
60
+ `Check the venue/organizer's Instagram for posts about this event — look for @${person.name.toLowerCase().replace(/\s+/g, '')} in tags or caption. If not tagged, search '${person.name} instagram'. Add verified URLs to featured[${i}].socials[].`,
61
+ ),
62
+ );
63
+ }
64
+
65
+ if (!person.context?.trim()) {
66
+ findings.push(
67
+ finding(
68
+ `featured[${i}].context`,
69
+ `${label} has no context.`,
70
+ `Visit ${person.name}'s Instagram profile — pull genre, bio, and notable work for featured[${i}].context. Include any Spotify/Bandcamp/website links from their bio.`,
71
+ ),
72
+ );
73
+ }
74
+ }
75
+ return findings;
76
+ }
77
+
78
+ function auditOrganizer(payload: ResearchPayload): AuditFinding[] {
79
+ if (!payload.organizer?.name?.trim()) {
80
+ return [
81
+ finding("organizer", "No organizer provided.", "Identify who is organizing this event and set organizer.name."),
82
+ ];
83
+ }
84
+
85
+ const name = payload.organizer.name;
86
+ if (!payload.organizer.socials?.length) {
87
+ return [
88
+ finding(
89
+ "organizer.socials",
90
+ `Organizer '${name}' has no social profiles.`,
91
+ `Find '${name}' on Instagram (browse their profile directly). Pull socials from their bio. Add URLs to organizer.socials[].`,
92
+ ),
93
+ ];
94
+ }
95
+ return [];
96
+ }
97
+
98
+ function auditVenue(payload: ResearchPayload): AuditFinding[] {
99
+ if (!payload.venue?.name?.trim()) {
100
+ return [finding("venue", "No venue name provided.", "Identify the venue from the event page and set venue.name.")];
101
+ }
102
+
103
+ const findings: AuditFinding[] = [];
104
+ const name = payload.venue.name;
105
+
106
+ if (!payload.venue.city?.trim()) {
107
+ findings.push(
108
+ finding("venue.city", `No city provided for venue '${name}'.`, `Set venue.city to the city where '${name}' is located.`),
109
+ );
110
+ }
111
+
112
+ if (!payload.venue.google_place_id?.trim()) {
113
+ const city = payload.venue.city?.trim() ?? "the city";
114
+ findings.push(
115
+ finding(
116
+ "venue.google_place_id",
117
+ `No google_place_id for venue '${name}'.`,
118
+ `Search Google Maps for '${name} ${city}' and extract the place ID from the share URL.`,
119
+ ),
120
+ );
121
+ }
122
+ return findings;
123
+ }
124
+
125
+ function auditEvent(payload: ResearchPayload): AuditFinding[] {
126
+ if (!payload.event) {
127
+ return [
128
+ finding(
129
+ "event",
130
+ "No event details provided.",
131
+ "Extract the event title, date, and type from the event page and populate the event section.",
132
+ ),
133
+ ];
134
+ }
135
+
136
+ const findings: AuditFinding[] = [];
137
+
138
+ if (!payload.event.title?.trim()) {
139
+ findings.push(finding("event.title", "No event title provided.", "Set event.title to the event name from the source page."));
140
+ }
141
+
142
+ if (!payload.event.date?.trim()) {
143
+ findings.push(
144
+ finding("event.date", "No event date provided.", "Set event.date in ISO 8601 format (e.g., '2026-04-05T11:30:00+02:00')."),
145
+ );
146
+ }
147
+
148
+ if (!payload.event.type?.trim()) {
149
+ findings.push(
150
+ finding(
151
+ "event.type",
152
+ "No event type provided.",
153
+ "Set event.type (e.g., 'club night', 'concert', 'food event', 'festival', 'exhibition', 'market').",
154
+ ),
155
+ );
156
+ }
157
+
158
+ if (!payload.event.urls?.length) {
159
+ findings.push(
160
+ finding(
161
+ "event.urls",
162
+ "No event URLs provided.",
163
+ "Add the source page URL to event.urls[]. Search for the event on RA, Facebook, or the venue's website and add those too.",
164
+ ),
165
+ );
166
+ } else if (payload.event.urls.length === 1) {
167
+ findings.push(
168
+ finding(
169
+ "event.urls",
170
+ "Only 1 event URL provided.",
171
+ "Search for this event on RA (ra.co), Facebook Events, or the venue's website and add additional URLs to event.urls[].",
172
+ ),
173
+ );
174
+ }
175
+
176
+ const isFree = payload.event.price?.trim().toLowerCase() === "free";
177
+ if (!payload.event.ticket_url?.trim() && !isFree) {
178
+ findings.push(
179
+ finding(
180
+ "event.ticket_url",
181
+ "No ticket URL provided.",
182
+ "Find the ticketing page for this event and set event.ticket_url. If the event is free, set event.price to 'Free'.",
183
+ ),
184
+ );
185
+ }
186
+
187
+ return findings;
188
+ }
189
+
190
+ export function formatAuditFindings(findings: AuditFinding[]): string {
191
+ const count = findings.length;
192
+ const lines: string[] = [
193
+ `\nResearch payload audit failed. ${count} issue${count === 1 ? "" : "s"} found:\n`,
194
+ ];
195
+
196
+ for (const f of findings) {
197
+ lines.push(` ${f.field}`);
198
+ lines.push(` ${f.message}`);
199
+ lines.push(` -> ${f.action}\n`);
200
+ }
201
+
202
+ lines.push("Fix these issues and retry.\n");
203
+ return lines.join("\n");
204
+ }
@@ -10,7 +10,7 @@ import {
10
10
  describe("validateResearchPayload", () => {
11
11
  it("accepts a full payload with all fields", () => {
12
12
  const payload = validateResearchPayload({
13
- performers: [
13
+ featured: [
14
14
  {
15
15
  name: "DJ Nobu",
16
16
  type: "DJ",
@@ -42,8 +42,8 @@ describe("validateResearchPayload", () => {
42
42
  },
43
43
  context: "Two rooms",
44
44
  });
45
- expect(payload.performers).toHaveLength(2);
46
- expect(payload.performers![0].type).toBe("DJ");
45
+ expect(payload.featured).toHaveLength(2);
46
+ expect(payload.featured![0].type).toBe("DJ");
47
47
  expect(payload.venue?.context).toBe("Underground club, 350 capacity");
48
48
  expect(payload.event?.type).toBe("club night");
49
49
  });
@@ -52,26 +52,34 @@ describe("validateResearchPayload", () => {
52
52
  expect(validateResearchPayload({})).toEqual({});
53
53
  });
54
54
 
55
- it("accepts a payload with only performers", () => {
55
+ it("accepts a payload with only featured", () => {
56
56
  const payload = validateResearchPayload({
57
- performers: [{ name: "Objekt" }],
57
+ featured: [{ name: "Objekt" }],
58
58
  });
59
- expect(payload.performers).toHaveLength(1);
59
+ expect(payload.featured).toHaveLength(1);
60
60
  });
61
61
 
62
- it("rejects a payload where performers is not an array", () => {
63
- expect(() => validateResearchPayload({ performers: "DJ Nobu" })).toThrow("Invalid research payload");
62
+ it("rejects a payload where featured is not an array", () => {
63
+ expect(() => validateResearchPayload({ featured: "DJ Nobu" })).toThrow("Invalid research payload");
64
64
  });
65
65
 
66
66
  it("rejects a performer missing name", () => {
67
- expect(() => validateResearchPayload({ performers: [{ genre: "techno" }] })).toThrow("Invalid research payload");
67
+ expect(() => validateResearchPayload({ featured: [{ genre: "techno" }] })).toThrow("Invalid research payload");
68
+ });
69
+
70
+ it("migrates legacy 'performers' key to 'featured'", () => {
71
+ const payload = validateResearchPayload({
72
+ performers: [{ name: "DJ Nobu", type: "DJ" }],
73
+ });
74
+ expect(payload.featured).toHaveLength(1);
75
+ expect(payload.featured![0].name).toBe("DJ Nobu");
68
76
  });
69
77
  });
70
78
 
71
79
  describe("stitchResearchContext", () => {
72
80
  it("produces formatted output preserving entity context", () => {
73
81
  const result = stitchResearchContext({
74
- performers: [
82
+ featured: [
75
83
  {
76
84
  name: "DJ Nobu",
77
85
  type: "DJ",
@@ -99,7 +107,7 @@ describe("stitchResearchContext", () => {
99
107
  context: "Cash only at door",
100
108
  });
101
109
 
102
- expect(result).toContain("## Performers");
110
+ expect(result).toContain("## Featured");
103
111
  expect(result).toContain("- DJ Nobu (DJ, techno)");
104
112
  expect(result).toContain("Berlin-based, known for long hypnotic sets");
105
113
  expect(result).toContain("Profiles: https://instagram.com/djnobu");
@@ -122,9 +130,9 @@ describe("stitchResearchContext", () => {
122
130
 
123
131
  it("omits sections without data", () => {
124
132
  const result = stitchResearchContext({
125
- performers: [{ name: "Objekt" }],
133
+ featured: [{ name: "Objekt" }],
126
134
  });
127
- expect(result).toContain("## Performers");
135
+ expect(result).toContain("## Featured");
128
136
  expect(result).not.toContain("## Organizer");
129
137
  expect(result).not.toContain("## Venue");
130
138
  expect(result).not.toContain("## Event");
@@ -169,9 +177,9 @@ describe("generateResearchSkeleton", () => {
169
177
 
170
178
  it("includes all entity sections with example data", () => {
171
179
  const skeleton = generateResearchSkeleton();
172
- expect(skeleton.performers![0].name).toBeTruthy();
173
- expect(skeleton.performers![0].type).toBeTruthy();
174
- expect(skeleton.performers![0].context).toBeTruthy();
180
+ expect(skeleton.featured![0].name).toBeTruthy();
181
+ expect(skeleton.featured![0].type).toBeTruthy();
182
+ expect(skeleton.featured![0].context).toBeTruthy();
175
183
  expect(skeleton.organizer!.context).toBeTruthy();
176
184
  expect(skeleton.venue!.context).toBeTruthy();
177
185
  expect(skeleton.event!.type).toBeTruthy();
@@ -1,7 +1,7 @@
1
1
  import { Type, type Static } from "@sinclair/typebox";
2
2
  import Ajv from "ajv";
3
3
 
4
- const PerformerSchema = Type.Object({
4
+ const FeaturedPersonSchema = Type.Object({
5
5
  name: Type.String(),
6
6
  type: Type.Optional(Type.String()),
7
7
  genre: Type.Optional(Type.String()),
@@ -33,7 +33,7 @@ const EventSchema = Type.Object({
33
33
  });
34
34
 
35
35
  export const ResearchPayloadSchema = Type.Object({
36
- performers: Type.Optional(Type.Array(PerformerSchema)),
36
+ featured: Type.Optional(Type.Array(FeaturedPersonSchema)),
37
37
  organizer: Type.Optional(OrganizerSchema),
38
38
  venue: Type.Optional(VenueSchema),
39
39
  event: Type.Optional(EventSchema),
@@ -46,6 +46,11 @@ const ajv = new Ajv({ allErrors: true });
46
46
  const validate = ajv.compile(ResearchPayloadSchema);
47
47
 
48
48
  export function validateResearchPayload(data: unknown): ResearchPayload {
49
+ // Backward compat: accept legacy "performers" key as "featured"
50
+ if (data && typeof data === "object" && "performers" in data && !("featured" in data)) {
51
+ const { performers, ...rest } = data as Record<string, unknown>;
52
+ data = { ...rest, featured: performers };
53
+ }
49
54
  if (validate(data)) {
50
55
  return data;
51
56
  }
@@ -55,7 +60,7 @@ export function validateResearchPayload(data: unknown): ResearchPayload {
55
60
 
56
61
  export function generateResearchSkeleton(): ResearchPayload {
57
62
  return {
58
- performers: [
63
+ featured: [
59
64
  {
60
65
  name: "DJ Nobu",
61
66
  type: "DJ",
@@ -91,9 +96,9 @@ export function generateResearchSkeleton(): ResearchPayload {
91
96
  context: "Amsterdam-based collective, running events since 2007. Annual festival + club nights.",
92
97
  },
93
98
  venue: {
94
- name: "Shelter Amsterdam",
99
+ name: "Shelter",
95
100
  city: "Amsterdam",
96
- google_place_id: "",
101
+ google_place_id: "ChIJX8_place_id_example",
97
102
  context: "Underground club beneath A'DAM Tower, 350 capacity, one room, Funktion-One sound.",
98
103
  },
99
104
  event: {
@@ -138,8 +143,8 @@ export function mapResearchToUploadFields(
138
143
  export function stitchResearchContext(payload: ResearchPayload): string {
139
144
  const sections: string[] = [];
140
145
 
141
- if (payload.performers?.length) {
142
- const lines = payload.performers.map((p) => {
146
+ if (payload.featured?.length) {
147
+ const lines = payload.featured.map((p) => {
143
148
  const parts = [`- ${p.name}`];
144
149
  const attrs: string[] = [];
145
150
  if (p.type) attrs.push(p.type);
@@ -149,7 +154,7 @@ export function stitchResearchContext(payload: ResearchPayload): string {
149
154
  if (p.context) parts.push(` ${p.context}`);
150
155
  return parts.join("\n");
151
156
  });
152
- sections.push(`## Performers\n${lines.join("\n")}`);
157
+ sections.push(`## Featured\n${lines.join("\n")}`);
153
158
  }
154
159
 
155
160
  if (payload.organizer?.name) {