@localpulse/cli 0.0.2 → 0.0.4

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.2",
3
+ "version": "0.0.4",
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
@@ -13,9 +13,11 @@ import {
13
13
  getCredentialsPath,
14
14
  getDefaultApiUrl,
15
15
  resolveApiUrl,
16
+ resolveToken,
16
17
  } from "./lib/credentials";
17
18
  import { loginWithToken } from "./lib/login";
18
19
  import { exitCodeForError, printError } from "./lib/output";
20
+ import { auditResearchPayload, formatAuditFindings } from "./lib/research-audit";
19
21
  import { readResearchPayload } from "./lib/research-reader";
20
22
  import {
21
23
  generateResearchSkeleton,
@@ -28,8 +30,7 @@ import {
28
30
  type DraftResult,
29
31
  uploadPoster,
30
32
  createDraft,
31
- uploadDraftMedia,
32
- detectMediaType,
33
+ verifyCliToken,
33
34
  } from "./lib/upload-client";
34
35
  import packageJson from "../package.json";
35
36
 
@@ -81,12 +82,14 @@ async function runAuth(parsed: ReturnType<typeof parseArgv>): Promise<void> {
81
82
  }
82
83
 
83
84
  const subcommand = parsed.positionals[0];
84
- if (!subcommand || (subcommand !== "login" && subcommand !== "logout")) {
85
- throw new Error("Usage: localpulse auth <login|logout>. Run `localpulse auth --help` for details.");
85
+ if (!subcommand || !["login", "logout", "status"].includes(subcommand)) {
86
+ throw new Error("Usage: localpulse auth <login|logout|status>. Run `localpulse auth --help` for details.");
86
87
  }
87
88
 
88
89
  if (subcommand === "login") {
89
90
  await runAuthLogin(parsed);
91
+ } else if (subcommand === "status") {
92
+ await runAuthStatus(parsed);
90
93
  } else {
91
94
  await runAuthLogout(parsed);
92
95
  }
@@ -125,6 +128,45 @@ async function runAuthLogout(parsed: ReturnType<typeof parseArgv>): Promise<void
125
128
  }
126
129
  }
127
130
 
131
+ async function runAuthStatus(parsed: ReturnType<typeof parseArgv>): Promise<void> {
132
+ const jsonOutput = hasOption(parsed, "json");
133
+ const token = await resolveToken();
134
+ const apiUrl = await resolveApiUrl();
135
+ const credentialsPath = getCredentialsPath();
136
+ const source = process.env.LP_TOKEN?.trim() ? "LP_TOKEN" : credentialsPath;
137
+
138
+ if (!token) {
139
+ if (jsonOutput) {
140
+ stdout.write(`${JSON.stringify({ authenticated: false, source: null, reason: "no_token" })}\n`);
141
+ } else {
142
+ stdout.write("Not authenticated. Run `localpulse auth login` or set LP_TOKEN.\n");
143
+ }
144
+ process.exitCode = 1;
145
+ return;
146
+ }
147
+
148
+ try {
149
+ const result = await verifyCliToken(apiUrl, token);
150
+ if (jsonOutput) {
151
+ stdout.write(`${JSON.stringify({ authenticated: true, email: result.email, api_url: apiUrl, source })}\n`);
152
+ } else {
153
+ stdout.write(`Authenticated as ${result.email}\n`);
154
+ stdout.write(` API: ${apiUrl}\n`);
155
+ stdout.write(` Credentials: ${source}\n`);
156
+ }
157
+ } catch (error) {
158
+ const message = error instanceof Error ? error.message : "Token verification failed";
159
+ if (jsonOutput) {
160
+ stdout.write(`${JSON.stringify({ authenticated: false, api_url: apiUrl, source, error: message })}\n`);
161
+ } else {
162
+ stdout.write(`Authentication failed: ${message}\n`);
163
+ stdout.write(` API: ${apiUrl}\n`);
164
+ stdout.write(` Credentials: ${source}\n`);
165
+ }
166
+ process.exitCode = 1;
167
+ }
168
+ }
169
+
128
170
  async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
129
171
  if (hasOption(parsed, "help")) {
130
172
  stdout.write(ingestHelp());
@@ -148,6 +190,19 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
148
190
  }
149
191
 
150
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
+
151
206
  const mapped = mapResearchToUploadFields(payload);
152
207
  const stitched = stitchResearchContext(payload);
153
208
  const explicitContext = readStringOption(parsed, "context");
@@ -156,7 +211,6 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
156
211
  const apiUrl = await resolveApiUrl();
157
212
  const dryRun = hasOption(parsed, "dry-run");
158
213
  const force = hasOption(parsed, "force");
159
- const jsonOutput = hasOption(parsed, "json");
160
214
  const token = dryRun ? "" : await requireToken();
161
215
 
162
216
  const uploadOptions = {
@@ -197,13 +251,16 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
197
251
  return;
198
252
  }
199
253
 
200
- // Default: draft flow
254
+ // Default: draft flow — create draft then submit to ingestion pipeline
201
255
  const draft = await runDraftIngest(apiUrl, token, file, uploadOptions);
256
+ process.stderr.write("Submitting to ingestion pipeline...\n");
257
+ const result = await uploadPoster(apiUrl, token, { ...uploadOptions, draftId: draft.id }) as UploadPosterResult;
202
258
  if (jsonOutput) {
203
- const baseUrl = toFrontendBaseUrl(apiUrl);
204
- stdout.write(`${JSON.stringify({ draft_id: draft.id, status: draft.status, edit_url: `${baseUrl}/publish/edit/${draft.id}` })}\n`);
259
+ stdout.write(`${JSON.stringify({ draft_id: draft.id, ...result })}\n`);
205
260
  } else {
206
- printDraftResult(draft, apiUrl);
261
+ printIngestResult(result);
262
+ const baseUrl = toFrontendBaseUrl(apiUrl);
263
+ stdout.write(`Draft: ${baseUrl}/publish/edit/${draft.id}\n`);
207
264
  }
208
265
  }
209
266
 
@@ -234,36 +291,11 @@ async function runDraftIngest(
234
291
 
235
292
  process.stderr.write("Creating draft...\n");
236
293
  const draft = await createDraft(apiUrl, token, options);
237
-
238
294
  process.stderr.write(`Draft created: ${draft.id}\n`);
239
- process.stderr.write("Uploading primary media...\n");
240
- await uploadDraftMedia(apiUrl, token, draft.id, primaryFile, {
241
- mediaType: detectMediaType(primaryFile),
242
- sortOrder: 0,
243
- isPrimary: true,
244
- });
245
-
246
- if (options.extraMedia?.length) {
247
- for (const [index, path] of options.extraMedia.entries()) {
248
- process.stderr.write(`Uploading extra media ${index + 1}...\n`);
249
- await uploadDraftMedia(apiUrl, token, draft.id, path, {
250
- mediaType: detectMediaType(path),
251
- sortOrder: index + 1,
252
- isPrimary: false,
253
- });
254
- }
255
- }
256
295
 
257
296
  return draft;
258
297
  }
259
298
 
260
- function printDraftResult(draft: DraftResult, apiUrl: string): void {
261
- const baseUrl = toFrontendBaseUrl(apiUrl);
262
- stdout.write(`\nDraft saved: ${draft.id}\n`);
263
- stdout.write("Open the publish dashboard to review and submit:\n");
264
- stdout.write(` ${baseUrl}/publish/edit/${draft.id}\n`);
265
- }
266
-
267
299
  function printIngestResult(result: UploadPosterResult): void {
268
300
  stdout.write(`${result.message}\n`);
269
301
  if (result.run_id) {
@@ -461,10 +493,12 @@ function authHelp(): string {
461
493
  Commands:
462
494
  login Authenticate with a Local Pulse CLI token
463
495
  logout Remove stored credentials
496
+ status Check authentication status and verify token
464
497
 
465
498
  Examples:
466
499
  localpulse auth login --token lp_cli_...
467
- LP_TOKEN=lp_cli_... localpulse auth login
500
+ localpulse auth status
501
+ localpulse auth status --json
468
502
  localpulse auth logout
469
503
  `;
470
504
  }
@@ -502,14 +536,36 @@ Research payload:
502
536
  --generate-skeleton Print an example research template and exit
503
537
 
504
538
  The payload is JSON with five top-level sections. All are optional,
505
- but the more you provide, the better the enrichment.
506
-
507
- performers[] Who is performing or presenting
508
- .name Full name (required per performer)
509
- .type What they are: "DJ", "band", "speaker", "visual artist"
510
- .genre Style or genre: "techno", "jazz", "stand-up comedy"
539
+ but richer research produces much better event listings.
540
+
541
+ Research guidance:
542
+ The source URL is a starting point, not the finish line. After extracting
543
+ what the page provides, search the web to fill gaps:
544
+ - For each featured person: search "{name}" to find their Instagram,
545
+ website, and background. Every person mentioned on the event page
546
+ should appear in featured[] with a type that fits (chef, DJ, host…).
547
+ - For the organizer: search "{name} instagram" and "{name} website".
548
+ - For the venue: search Google Maps for "{venue} {city}" and extract
549
+ the google_place_id from the URL.
550
+ - For additional URLs: search for the event on RA, Facebook, or the
551
+ venue's own website.
552
+
553
+ Quality checklist (aim to fill as many as possible):
554
+ ✓ Featured person socials: Instagram, website, Spotify, Bandcamp, RA
555
+ ✓ Organizer socials: Instagram, website, RA promoter page
556
+ ✓ Venue google_place_id (search Google Maps → share → extract place ID)
557
+ ✓ Multiple event.urls: venue page, RA, Facebook event, artist page
558
+ ✓ Embed URLs in context: Spotify album/track links, YouTube videos
559
+ ✓ Featured person context: background, notable work, affiliations
560
+ ✓ All featured people as separate entries (support acts, guest chefs, co-hosts)
561
+
562
+ featured[] Who is featured — performers, chefs, hosts, speakers
563
+ .name Full name (required per person)
564
+ .type Their role: "DJ", "chef", "band", "host", "speaker",
565
+ "visual artist", "cook"
566
+ .genre Style, genre, or cuisine: "techno", "jazz", "Kerala"
511
567
  .socials[] Profile URLs: Instagram, RA, Bandcamp, personal site
512
- .context Anything else: bio, residencies, notable releases
568
+ .context Anything else: bio, notable work, affiliations
513
569
 
514
570
  organizer Who is putting on the event
515
571
  .name Collective, promoter, or brand name (required)
@@ -526,7 +582,8 @@ Research payload:
526
582
  .title Event name
527
583
  .date ISO 8601 datetime (e.g. "2026-03-14T22:00:00+01:00")
528
584
  .type Kind of event: "club night", "festival", "exhibition",
529
- "workshop", "concert", "open air", "listening session"
585
+ "workshop", "concert", "open air", "listening session",
586
+ "pop-up dinner", "food event", "tasting", "market"
530
587
  .price Ticket price info: "€15-25", "Free", "Sold out"
531
588
  .urls[] Source pages: RA, venue site, Facebook event
532
589
  .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
+ `Search '${person.name} instagram' and '${person.name} website'. Add 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
+ `Search '${person.name}' and write a 1-2 sentence bio for featured[${i}].context.`,
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
+ `Search '${name} instagram' and '${name} website'. 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,34 +60,59 @@ 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",
62
67
  genre: "techno",
63
- socials: ["https://instagram.com/djnobu", "https://ra.co/dj/djnobu"],
64
- context: "Berlin-based Japanese DJ, known for long hypnotic sets",
68
+ socials: [
69
+ "https://instagram.com/djnobu",
70
+ "https://ra.co/dj/djnobu",
71
+ "https://soundcloud.com/djnobu",
72
+ "https://djnobu.bandcamp.com",
73
+ "https://open.spotify.com/artist/0abc123",
74
+ ],
75
+ context:
76
+ "Berlin-based Japanese DJ, known for long hypnotic sets. Resident at Future Terror (Tokyo). Recent release: 'Prism' on Bitta (2025).",
77
+ },
78
+ {
79
+ name: "Nene H",
80
+ type: "DJ",
81
+ genre: "experimental electronic",
82
+ socials: [
83
+ "https://instagram.com/nenehmusic",
84
+ "https://ra.co/dj/neneh",
85
+ ],
86
+ context: "Support. Tehran-born, Berlin-based. Known for deconstructed club music.",
65
87
  },
66
88
  ],
67
89
  organizer: {
68
90
  name: "Dekmantel",
69
- socials: ["https://instagram.com/daboratorium"],
70
- context: "Amsterdam-based collective, running events since 2007",
91
+ socials: [
92
+ "https://instagram.com/daboratorium",
93
+ "https://www.dekmantel.com",
94
+ "https://ra.co/promoters/16478",
95
+ ],
96
+ context: "Amsterdam-based collective, running events since 2007. Annual festival + club nights.",
71
97
  },
72
98
  venue: {
73
- name: "Shelter Amsterdam",
99
+ name: "Shelter",
74
100
  city: "Amsterdam",
75
- google_place_id: "",
76
- context: "Underground club beneath A'DAM Tower, 350 capacity, one room",
101
+ google_place_id: "ChIJX8_place_id_example",
102
+ context: "Underground club beneath A'DAM Tower, 350 capacity, one room, Funktion-One sound.",
77
103
  },
78
104
  event: {
79
105
  title: "Shelter Presents: DJ Nobu",
80
106
  date: "2026-03-14T22:00:00+01:00",
81
107
  type: "club night",
82
108
  price: "€15-25",
83
- urls: ["https://ra.co/events/1234567"],
109
+ urls: [
110
+ "https://ra.co/events/1234567",
111
+ "https://www.dekmantel.com/events/shelter-nobu",
112
+ ],
84
113
  ticket_url: "https://tickets.example.com/shelter-nobu",
85
- context: "Part of ADE week. Doors at 22:00. Cash only at door.",
114
+ context:
115
+ "Part of ADE week. Doors at 22:00. Cash only at door. DJ Nobu plays an extended 4-hour set.",
86
116
  },
87
117
  context: "",
88
118
  };
@@ -113,8 +143,8 @@ export function mapResearchToUploadFields(
113
143
  export function stitchResearchContext(payload: ResearchPayload): string {
114
144
  const sections: string[] = [];
115
145
 
116
- if (payload.performers?.length) {
117
- const lines = payload.performers.map((p) => {
146
+ if (payload.featured?.length) {
147
+ const lines = payload.featured.map((p) => {
118
148
  const parts = [`- ${p.name}`];
119
149
  const attrs: string[] = [];
120
150
  if (p.type) attrs.push(p.type);
@@ -124,7 +154,7 @@ export function stitchResearchContext(payload: ResearchPayload): string {
124
154
  if (p.context) parts.push(` ${p.context}`);
125
155
  return parts.join("\n");
126
156
  });
127
- sections.push(`## Performers\n${lines.join("\n")}`);
157
+ sections.push(`## Featured\n${lines.join("\n")}`);
128
158
  }
129
159
 
130
160
  if (payload.organizer?.name) {
@@ -23,6 +23,7 @@ export type IngestUploadOptions = {
23
23
  venue?: string;
24
24
  extraMedia?: string[];
25
25
  dryRun?: boolean;
26
+ draftId?: string;
26
27
  };
27
28
 
28
29
  export type UploadDryRunResult = {
@@ -88,6 +89,7 @@ export async function uploadPoster(
88
89
  if (options.city) {
89
90
  setOptionalField(form, "venue_city", options.city);
90
91
  }
92
+ setOptionalField(form, "draft_id", options.draftId);
91
93
 
92
94
  if (options.extraMedia?.length) {
93
95
  const mediaTypes: string[] = [];