@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 +5 -5
- package/package.json +1 -1
- package/src/index.test.ts +44 -4
- package/src/index.ts +100 -43
- 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 +45 -15
- package/src/lib/upload-client.ts +2 -0
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
|
@@ -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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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,
|
|
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
|
-
|
|
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,34 +60,59 @@ 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",
|
|
62
67
|
genre: "techno",
|
|
63
|
-
socials: [
|
|
64
|
-
|
|
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: [
|
|
70
|
-
|
|
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
|
|
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: [
|
|
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:
|
|
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.
|
|
117
|
-
const lines = payload.
|
|
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(`##
|
|
157
|
+
sections.push(`## Featured\n${lines.join("\n")}`);
|
|
128
158
|
}
|
|
129
159
|
|
|
130
160
|
if (payload.organizer?.name) {
|
package/src/lib/upload-client.ts
CHANGED
|
@@ -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[] = [];
|