@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 +5 -5
- package/package.json +1 -1
- package/src/index.test.ts +44 -4
- package/src/index.ts +50 -11
- package/src/lib/research-audit.test.ts +242 -0
- package/src/lib/research-audit.ts +204 -0
- package/src/lib/research-schema.test.ts +24 -16
- package/src/lib/research-schema.ts +13 -8
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
|
-
**
|
|
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
|
-
|
|
19
|
-
|
|
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
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("--
|
|
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.
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
✓
|
|
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
|
-
✓
|
|
535
|
-
✓
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
.name Full name (required per
|
|
539
|
-
.type
|
|
540
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
46
|
-
expect(payload.
|
|
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
|
|
55
|
+
it("accepts a payload with only featured", () => {
|
|
56
56
|
const payload = validateResearchPayload({
|
|
57
|
-
|
|
57
|
+
featured: [{ name: "Objekt" }],
|
|
58
58
|
});
|
|
59
|
-
expect(payload.
|
|
59
|
+
expect(payload.featured).toHaveLength(1);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it("rejects a payload where
|
|
63
|
-
expect(() => validateResearchPayload({
|
|
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({
|
|
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
|
-
|
|
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("##
|
|
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
|
-
|
|
133
|
+
featured: [{ name: "Objekt" }],
|
|
126
134
|
});
|
|
127
|
-
expect(result).toContain("##
|
|
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.
|
|
173
|
-
expect(skeleton.
|
|
174
|
-
expect(skeleton.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
142
|
-
const lines = payload.
|
|
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(`##
|
|
157
|
+
sections.push(`## Featured\n${lines.join("\n")}`);
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
if (payload.organizer?.name) {
|