diff --git a/cspell.json b/cspell.json index 0665fe4..2d147d3 100644 --- a/cspell.json +++ b/cspell.json @@ -43,5 +43,6 @@ "pnpm-lock.yaml", ".husky/_/**" ], - "ignoreRegExpList": ["Urls", "Email", "HexValues", "Base64"] + "ignoreRegExpList": ["Urls", "Email", "HexValues", "Base64"], + "words": ["CPPTORONTO", "urlnames"] } diff --git a/packages/meetup/README.md b/packages/meetup/README.md index 6ae3515..f36ee24 100644 --- a/packages/meetup/README.md +++ b/packages/meetup/README.md @@ -92,6 +92,68 @@ the API accepts no offset. So `event.date` has two possible readings, and the Omitting `timezone` when your dates really are UTC silently shifts every event by the zone's offset, so set it whenever your source stores real instants. +### Network events (one session, several groups) + +A Meetup **Pro network** cross-posts the same session to every group in the +network. List the extra groups in `groups`, and give each its own host — groups +in a network almost always have different organizers: + +```json +{ + "meetup": { + "groupUrlname": "chicago-c-cpp-users-group", + "timezone": "America/Chicago", + "venues": { "online": "online" }, + "groups": ["cpp-serbia", "CPPTORONTO"], + "hosts": { "Rob Douglas": 13296813, "Alex Smith": 256192100, "Jordan Lee": 274644230 }, + "groupHosts": { + "chicago-c-cpp-users-group": ["Rob Douglas"], + "cpp-serbia": ["Alex Smith"], + "CPPTORONTO": ["Jordan Lee"] + } + } +} +``` + +`groupUrlname` is always the first target, then `groups` in order, de-duplicated +case-insensitively (Meetup urlnames are case-insensitive, and a network may +report `CPPTORONTO` where your config says `cpptoronto`). + +```bash +# every group in the config +bunx coopkit-meetup create-from-json --groups all --dry-run event.json + +# just some of them +bunx coopkit-meetup create-from-json --groups "cpp-serbia,CPPTORONTO" event.json +``` + +Without `--groups` only `groupUrlname` is used, and `--output` keeps its +original single-event shape — so existing setups are unaffected. With +`--groups`, `--output` gets a per-group breakdown instead: + +```json +{ + "status": "partial", + "groups": [ + { "groupUrlname": "chicago-c-cpp-users-group", "ok": true, "status": "created", "eventId": "1234", "eventUrl": "https://…", "photoAttached": false }, + { "groupUrlname": "cpp-serbia", "ok": false, "error": "createEvent returned no event. …" } + ] +} +``` + +Groups are processed **sequentially**, and one group failing does not stop the +others — every outcome is reported so you can retry just the failures. The exit +code is 1 if any group failed. There is **no rollback**: drafts already created +stay, so a retry needs the failed groups named explicitly or it will duplicate +the ones that worked. + +> **Why not `proNetworkEvents`?** `CreateEventInput` has a `proNetworkEvents` +> input that propagates one event across a network via a saved `filterId`. That +> filter cannot be enumerated through the API, so which groups it would reach is +> unverifiable from code. Naming the groups is explicit, and each draft can be +> reviewed or deleted on its own. Every account member must still have rights in +> each target group — being a Pro network admin is not by itself enough. + ### Hosts `eventHosts` takes Meetup **member IDs**, and the account must be a member of diff --git a/packages/meetup/src/cli.ts b/packages/meetup/src/cli.ts index 3bc339c..37daa66 100644 --- a/packages/meetup/src/cli.ts +++ b/packages/meetup/src/cli.ts @@ -2,8 +2,13 @@ import fs from "node:fs"; import type { NormalizedEvent } from "@coopkit/core"; import { defineCommand, runMain } from "citty"; -import { loadMeetupConfig, resolveHostIds } from "./config.js"; -import { createMeetupDraft, createMeetupDraftFromFile } from "./create-event.js"; +import { loadMeetupConfig, resolveGroupTargets, resolveHostIds } from "./config.js"; +import { + type CreateMeetupDraftsResult, + createMeetupDraft, + createMeetupDraftFromFile, + createMeetupDrafts, +} from "./create-event.js"; import { formatVenueKey, listVenues } from "./list-venues.js"; import { loadEnvFile } from "./load-env.js"; @@ -12,20 +17,55 @@ import { loadEnvFile } from "./load-env.js"; * — notably GitHub Actions — can capture the created event's id/url. Keeps the * package free of any Actions-specific coupling: it just emits JSON. */ +/** + * Collapse per-group outcomes into one status: "partial" if any group failed, + * otherwise whatever the groups agree on ("dry-run" or "created"). Reporting a + * dry run as "created" would be a lie a caller might act on. + */ +function aggregateStatus(result: CreateMeetupDraftsResult): string { + if (result.results.some((r) => !r.ok)) return "partial"; + const statuses = new Set(result.results.map((r) => (r.ok ? r.result.status : "failed"))); + return statuses.size === 1 ? ([...statuses][0] ?? "created") : "mixed"; +} + +type SingleResult = { + status: string; + eventId?: string; + eventUrl?: string; + photoAttached?: boolean; +}; + +function summarizeSingle(result: SingleResult) { + return result.status === "created" + ? { + status: result.status, + eventId: result.eventId ?? "", + eventUrl: result.eventUrl ?? "", + photoAttached: result.photoAttached ?? false, + } + : { status: result.status }; +} + function writeResultFile( outputPath: string | undefined, - result: { status: string; eventId?: string; eventUrl?: string; photoAttached?: boolean } + result: SingleResult | CreateMeetupDraftsResult ): void { if (!outputPath) return; + + // A multi-group run has no single id/url to report, so emit the per-group + // breakdown instead of flattening it to one (arbitrary) event. const payload = - result.status === "created" + "results" in result ? { - status: result.status, - eventId: result.eventId ?? "", - eventUrl: result.eventUrl ?? "", - photoAttached: result.photoAttached ?? false, + status: aggregateStatus(result), + groups: result.results.map((r) => + r.ok + ? { groupUrlname: r.groupUrlname, ok: true, ...summarizeSingle(r.result) } + : { groupUrlname: r.groupUrlname, ok: false, error: r.error } + ), } - : { status: result.status }; + : summarizeSingle(result); + fs.writeFileSync(outputPath, `${JSON.stringify(payload)}\n`); } @@ -137,6 +177,11 @@ const createFromJsonCmd = defineCommand({ description: "Host name(s) from meetup.hosts, comma-separated. Defaults to meetup.defaultHosts.", }, + groups: { + type: "string", + description: + 'Group urlname(s) to create the event in, comma-separated, or "all" for every group in the config. Defaults to meetup.groupUrlname only.', + }, output: { type: "string", description: @@ -148,16 +193,44 @@ const createFromJsonCmd = defineCommand({ const config = loadMeetupConfig(args.config); const raw = args.file ? fs.readFileSync(args.file, "utf8") : readStdin(); const event = parseNormalizedEvent(raw); - const hosts = resolveHostIds(config, parseHostArg(args.host)); - const result = await createMeetupDraft({ + const hostNames = parseHostArg(args.host); + const requested = parseHostArg(args.groups); + const multiGroup = requested !== undefined; + const only = + requested && !(requested.length === 1 && requested[0]?.toLowerCase() === "all") + ? requested + : undefined; + const targets = resolveGroupTargets(config, { + ...(only !== undefined ? { only } : {}), + ...(hostNames !== undefined ? { hostNames } : {}), + }); + + // Single-group runs keep returning the bare result, so existing callers + // parsing --output see no change; --groups opts into the per-group report. + if (!multiGroup) { + const target = targets[0]; + if (!target) throw new Error("No group resolved from the config."); + const result = await createMeetupDraft({ + event, + groupUrlname: target.urlname, + venues: config.venues, + ...(config.timezone !== undefined ? { timezone: config.timezone } : {}), + ...(target.hosts.length > 0 ? { hosts: target.hosts } : {}), + dryRun: Boolean(args["dry-run"]), + }); + writeResultFile(args.output, result); + return; + } + + const result = await createMeetupDrafts({ event, - groupUrlname: config.groupUrlname, + groups: targets, venues: config.venues, ...(config.timezone !== undefined ? { timezone: config.timezone } : {}), - ...(hosts.length > 0 ? { hosts } : {}), dryRun: Boolean(args["dry-run"]), }); writeResultFile(args.output, result); + if (result.results.some((r) => !r.ok)) process.exitCode = 1; }, }); diff --git a/packages/meetup/src/config.test.ts b/packages/meetup/src/config.test.ts index d08bb00..acc927e 100644 --- a/packages/meetup/src/config.test.ts +++ b/packages/meetup/src/config.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { type MeetupConfig, loadMeetupConfig, resolveHostIds } from "./config.js"; +import { + type MeetupConfig, + loadMeetupConfig, + resolveGroupTargets, + resolveHostIds, +} from "./config.js"; function writeConfig(contents: unknown): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coopkit-config-")); @@ -89,3 +94,108 @@ describe("resolveHostIds", () => { expect(() => resolveHostIds(cfg, ["Nobody"])).toThrow(/Known hosts: "Rob Douglas"/); }); }); + +describe("loadMeetupConfig groups/groupHosts", () => { + it("loads groups and groupHosts", () => { + const cfg = loadMeetupConfig( + writeConfig({ + meetup: { + ...BASE, + hosts: { "Rob Douglas": 13296813 }, + groups: ["cpp-serbia"], + groupHosts: { "chicago-c-cpp-users-group": ["Rob Douglas"] }, + }, + }) + ); + expect(cfg.groups).toEqual(["cpp-serbia"]); + expect(cfg.groupHosts).toEqual({ "chicago-c-cpp-users-group": ["Rob Douglas"] }); + }); + + it("rejects a non-array groups", () => { + expect(() => loadMeetupConfig(writeConfig({ meetup: { ...BASE, groups: "a" } }))).toThrow( + /meetup.groups must be an array/ + ); + }); + + it("rejects a groupHosts name that is not in hosts", () => { + expect(() => + loadMeetupConfig( + writeConfig({ + meetup: { ...BASE, hosts: { Rob: 1 }, groupHosts: { "some-group": ["Nope"] } }, + }) + ) + ).toThrow(/groupHosts\["some-group"\] entry "Nope" is not a key of meetup.hosts/); + }); +}); + +describe("resolveGroupTargets", () => { + const cfg: MeetupConfig = { + groupUrlname: "chicago-c-cpp-users-group", + venues: { online: "online" }, + hosts: { "Rob Douglas": 13296813, "Alex Smith": 256192100, "Jordan Lee": 274644230 }, + defaultHosts: ["Rob Douglas"], + groups: ["cpp-serbia", "CPPTORONTO"], + groupHosts: { + "chicago-c-cpp-users-group": ["Rob Douglas"], + "cpp-serbia": ["Alex Smith"], + cpptoronto: ["Jordan Lee"], + }, + }; + + it("puts groupUrlname first, then groups in order", () => { + expect(resolveGroupTargets(cfg).map((t) => t.urlname)).toEqual([ + "chicago-c-cpp-users-group", + "cpp-serbia", + "CPPTORONTO", + ]); + }); + + it("resolves per-group hosts, matching keys case-insensitively", () => { + const targets = resolveGroupTargets(cfg); + expect(targets.map((t) => t.hosts)).toEqual([[13296813], [256192100], [274644230]]); + }); + + it("falls back to defaultHosts for a group with no override", () => { + const targets = resolveGroupTargets({ ...cfg, groupHosts: {} }); + expect(targets.every((t) => t.hosts[0] === 13296813)).toBe(true); + }); + + it("de-duplicates groupUrlname repeated in groups, case-insensitively", () => { + const targets = resolveGroupTargets({ + ...cfg, + groups: ["CHICAGO-C-CPP-USERS-GROUP", "cpp-serbia"], + }); + expect(targets.map((t) => t.urlname)).toEqual(["chicago-c-cpp-users-group", "cpp-serbia"]); + }); + + it("narrows to the requested groups", () => { + expect(resolveGroupTargets(cfg, { only: ["cpp-serbia"] }).map((t) => t.urlname)).toEqual([ + "cpp-serbia", + ]); + }); + + it("matches the `only` filter case-insensitively", () => { + expect(resolveGroupTargets(cfg, { only: ["cpptoronto"] }).map((t) => t.urlname)).toEqual([ + "CPPTORONTO", + ]); + }); + + it("throws when a requested group is not in the config", () => { + expect(() => resolveGroupTargets(cfg, { only: ["winnipeg-cpp"] })).toThrow( + /"winnipeg-cpp" are not in this config/ + ); + }); + + it("returns just the one group when no groups list is configured", () => { + const targets = resolveGroupTargets({ + groupUrlname: "solo-group", + venues: { online: "online" }, + }); + expect(targets).toEqual([{ urlname: "solo-group", hosts: [] }]); + }); + + it("lets an explicit hostNames override apply to groups without a groupHosts entry", () => { + const targets = resolveGroupTargets({ ...cfg, groupHosts: {} }, { hostNames: ["Alex Smith"] }); + expect(targets.every((t) => t.hosts[0] === 256192100)).toBe(true); + }); +}); diff --git a/packages/meetup/src/config.ts b/packages/meetup/src/config.ts index 103656e..b69d257 100644 --- a/packages/meetup/src/config.ts +++ b/packages/meetup/src/config.ts @@ -15,6 +15,18 @@ export interface MeetupConfig { hosts?: Record; /** Names from `hosts` to list as event hosts when an event names none itself. */ defaultHosts?: string[]; + /** + * Additional group urlnames a single event should be created in, for a Meetup + * Pro network whose groups cross-post the same session. `groupUrlname` is + * always the first target; these follow, in order. + */ + groups?: string[]; + /** + * Per-group host overrides, keyed by group urlname, with values being names + * from `hosts`. Each group in a network usually has its own organizer, so a + * single `defaultHosts` rarely fits all of them. + */ + groupHosts?: Record; } export interface CoopkitConfig { @@ -70,6 +82,35 @@ export function loadMeetupConfig(configPath?: string): MeetupConfig { } } } + if (meetup.groups !== undefined) { + if ( + !Array.isArray(meetup.groups) || + meetup.groups.some((g) => typeof g !== "string" || g === "") + ) { + throw new Error(`${resolved}: meetup.groups must be an array of non-empty group urlnames.`); + } + } + if (meetup.groupHosts !== undefined) { + if (typeof meetup.groupHosts !== "object" || meetup.groupHosts === null) { + throw new Error( + `${resolved}: meetup.groupHosts must be an object mapping group urlnames to host-name arrays.` + ); + } + for (const [group, names] of Object.entries(meetup.groupHosts)) { + if (!Array.isArray(names)) { + throw new Error( + `${resolved}: meetup.groupHosts[${JSON.stringify(group)}] must be an array.` + ); + } + for (const name of names) { + if (typeof name !== "string" || !meetup.hosts || !(name in meetup.hosts)) { + throw new Error( + `${resolved}: meetup.groupHosts[${JSON.stringify(group)}] entry ${JSON.stringify(name)} is not a key of meetup.hosts.` + ); + } + } + } + } if (meetup.defaultHosts !== undefined) { if (!Array.isArray(meetup.defaultHosts)) { throw new Error( @@ -107,3 +148,59 @@ export function resolveHostIds(config: MeetupConfig, names?: string[]): number[] return id; }); } + +/** One group an event should be created in, with its hosts already resolved. */ +export interface GroupTarget { + urlname: string; + hosts: number[]; +} + +/** + * Expand config into the ordered list of groups to create the event in. + * + * `groupUrlname` is always first, then `groups`, de-duplicated + * case-insensitively (Meetup urlnames are case-insensitive, and a network can + * report a group as "CPPTORONTO" while a config says "cpptoronto"). Hosts come + * from `groupHosts[urlname]` when present, else the `hostNames` override, else + * `defaultHosts`. + */ +export function resolveGroupTargets( + config: MeetupConfig, + options: { only?: string[]; hostNames?: string[] } = {} +): GroupTarget[] { + const all = [config.groupUrlname, ...(config.groups ?? [])]; + + const seen = new Set(); + const ordered = all.filter((urlname) => { + const key = urlname.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + let selected = ordered; + if (options.only && options.only.length > 0) { + const wanted = new Set(options.only.map((g) => g.toLowerCase())); + selected = ordered.filter((urlname) => wanted.has(urlname.toLowerCase())); + const missing = options.only.filter( + (g) => !ordered.some((u) => u.toLowerCase() === g.toLowerCase()) + ); + if (missing.length > 0) { + throw new Error( + `Group(s) ${missing.map((g) => JSON.stringify(g)).join(", ")} are not in this config. ` + + `Known: ${ordered.map((g) => JSON.stringify(g)).join(", ")}.` + ); + } + } + + // Match groupHosts keys case-insensitively too, for the same reason. + const hostsByGroup = new Map( + Object.entries(config.groupHosts ?? {}).map(([g, names]) => [g.toLowerCase(), names]) + ); + + return selected.map((urlname) => { + const perGroup = hostsByGroup.get(urlname.toLowerCase()); + const names = perGroup ?? options.hostNames; + return { urlname, hosts: resolveHostIds(config, names) }; + }); +} diff --git a/packages/meetup/src/create-drafts.test.ts b/packages/meetup/src/create-drafts.test.ts new file mode 100644 index 0000000..7cc48aa --- /dev/null +++ b/packages/meetup/src/create-drafts.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "bun:test"; +import type { NormalizedEvent } from "@coopkit/core"; +import { createMeetupDrafts } from "./create-event.js"; + +const EVENT: NormalizedEvent = { + id: "2026-08-22-andy-soffer", + title: "(GlobalCpp) Refactoring C++ Today", + date: new Date("2026-08-22T16:00:00Z"), + duration: "PT1H", + venueKey: "online", + description: "A pragmatic survey.", +}; + +const VENUES = { online: "online" } as const; + +let calls = 0; + +// dryRun keeps this offline: createMeetupDraft returns the built payload +// without touching the network. +async function dryRunAcross(groups: Array<{ urlname: string; hosts?: number[] }>) { + return createMeetupDrafts({ + event: EVENT, + groups, + venues: { ...VENUES }, + timezone: "America/Chicago", + dryRun: true, + log: () => {}, + }); +} + +describe("createMeetupDrafts", () => { + it("creates one draft per group, in order", async () => { + const { results } = await dryRunAcross([ + { urlname: "chicago-c-cpp-users-group", hosts: [13296813] }, + { urlname: "cpp-serbia", hosts: [256192100] }, + { urlname: "CPPTORONTO", hosts: [274644230] }, + ]); + + expect(results.map((r) => r.groupUrlname)).toEqual([ + "chicago-c-cpp-users-group", + "cpp-serbia", + "CPPTORONTO", + ]); + expect(results.every((r) => r.ok)).toBe(true); + }); + + it("gives each group its own groupUrlname and hosts in the payload", async () => { + const { results } = await dryRunAcross([ + { urlname: "chicago-c-cpp-users-group", hosts: [13296813] }, + { urlname: "cpp-serbia", hosts: [256192100] }, + ]); + + const payloads = results.map((r) => + r.ok && r.result.status === "dry-run" ? r.result.payload : undefined + ); + expect(payloads[0]?.groupUrlname).toBe("chicago-c-cpp-users-group"); + expect(payloads[0]?.eventHosts).toEqual([13296813]); + expect(payloads[1]?.groupUrlname).toBe("cpp-serbia"); + expect(payloads[1]?.eventHosts).toEqual([256192100]); + }); + + it("applies the shared timezone to every group", async () => { + const { results } = await dryRunAcross([ + { urlname: "chicago-c-cpp-users-group" }, + { urlname: "cpp-serbia" }, + ]); + for (const r of results) { + const payload = r.ok && r.result.status === "dry-run" ? r.result.payload : undefined; + expect(payload?.startDateTime).toBe("2026-08-22T11:00:00"); + } + }); + + it("omits eventHosts for a group with no hosts", async () => { + const { results } = await dryRunAcross([{ urlname: "solo-group" }]); + const payload = + results[0]?.ok && results[0].result.status === "dry-run" + ? results[0].result.payload + : undefined; + expect(payload && "eventHosts" in payload).toBe(false); + }); + + it("reports a per-group failure without aborting the rest", async () => { + calls = 0; + // An unknown venue key fails inside the loop for that group only. + const { results } = await createMeetupDrafts({ + event: EVENT, + groups: [ + { urlname: "good-group" }, + { urlname: "bad-group" }, + { urlname: "another-good-group" }, + ], + // A Proxy venue map that only misses the lookup for one group would need + // per-group venues, which the API does not have; instead drive the failure + // from the event by making the second call throw via a getter. + venues: new Proxy( + { ...VENUES }, + { + has(target, key) { + calls += 1; + if (calls === 2) return false; // second group: venue "not found" + return key in target; + }, + } + ) as Record, + timezone: "America/Chicago", + dryRun: true, + log: () => {}, + }); + + expect(results.map((r) => r.ok)).toEqual([true, false, true]); + const failure = results[1]; + expect(failure?.ok === false && failure.error).toMatch(/Unknown venue/); + }); + + it("throws when given no groups at all", async () => { + await expect( + createMeetupDrafts({ + event: EVENT, + groups: [], + venues: { ...VENUES }, + dryRun: true, + log: () => {}, + }) + ).rejects.toThrow(/no groups/); + }); +}); + +describe("multi-group --output status", () => { + // Mirrors cli.ts's aggregateStatus contract: a dry run must never be + // reported as "created". + it("reports dry-run runs as dry-run, not created", async () => { + const { results } = await dryRunAcross([ + { urlname: "chicago-c-cpp-users-group" }, + { urlname: "cpp-serbia" }, + ]); + const statuses = new Set(results.map((r) => (r.ok ? r.result.status : "failed"))); + expect([...statuses]).toEqual(["dry-run"]); + }); +}); diff --git a/packages/meetup/src/create-event.ts b/packages/meetup/src/create-event.ts index e08da4b..56fbbad 100644 --- a/packages/meetup/src/create-event.ts +++ b/packages/meetup/src/create-event.ts @@ -283,3 +283,78 @@ export async function createMeetupDraftFromFile( }, }); } + +export interface CreateMeetupDraftsOptions { + event: NormalizedEvent; + /** Groups to create the event in, in order, each with its own hosts. */ + groups: Array<{ urlname: string; hosts?: number[] }>; + venues: VenueMap; + /** IANA timezone of the groups. See `BuildPayloadInput.timezone`. */ + timezone?: string; + dryRun?: boolean; + credentials?: MeetupCredentials; + log?: (message: string) => void; + onCreated?: CreateMeetupDraftOptions["onCreated"]; +} + +export type CreateMeetupDraftsResult = { + results: Array< + { groupUrlname: string } & ( + | { ok: true; result: CreateMeetupDraftResult } + | { ok: false; error: string } + ) + >; +}; + +/** + * Create the same event as a Draft in several groups — the Meetup Pro network + * case, where one session is cross-posted to every group in the network. + * + * Meetup's native alternative is `CreateEventInput.proNetworkEvents`, which + * propagates one event across a network via a saved `filterId`. That filter + * cannot be enumerated through the API, so which groups it would reach is + * unverifiable from code; creating one event per named group is explicit, + * and each draft can be inspected or deleted on its own. + * + * Groups are processed **sequentially** — a partial failure must not race — and + * one group failing does not stop the rest. Every outcome is reported, so the + * caller can retry only the groups that failed. There is no cross-group + * rollback: on partial failure the drafts that succeeded stay. + */ +export async function createMeetupDrafts( + options: CreateMeetupDraftsOptions +): Promise { + const log = options.log ?? ((m) => console.error(m)); + if (options.groups.length === 0) { + throw new Error("createMeetupDrafts called with no groups."); + } + + const results: CreateMeetupDraftsResult["results"] = []; + for (const [index, group] of options.groups.entries()) { + log(`[${index + 1}/${options.groups.length}] ${group.urlname}`); + try { + const result = await createMeetupDraft({ + event: options.event, + groupUrlname: group.urlname, + venues: options.venues, + ...(options.timezone !== undefined ? { timezone: options.timezone } : {}), + ...(group.hosts !== undefined ? { hosts: group.hosts } : {}), + ...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}), + ...(options.credentials !== undefined ? { credentials: options.credentials } : {}), + log, + ...(options.onCreated !== undefined ? { onCreated: options.onCreated } : {}), + }); + results.push({ groupUrlname: group.urlname, ok: true, result }); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + log(`[error] ${group.urlname}: ${error}`); + results.push({ groupUrlname: group.urlname, ok: false, error }); + } + } + + const failed = results.filter((r) => !r.ok); + const failedNote = + failed.length > 0 ? `; failed: ${failed.map((f) => f.groupUrlname).join(", ")}` : ""; + log(`Done: ${results.length - failed.length}/${results.length} group(s) succeeded${failedNote}`); + return { results }; +} diff --git a/packages/meetup/src/index.ts b/packages/meetup/src/index.ts index b98ac5c..8be1033 100644 --- a/packages/meetup/src/index.ts +++ b/packages/meetup/src/index.ts @@ -18,16 +18,27 @@ export { } from "./payload.js"; export type { BuildPayloadInput, CreateEventPayload } from "./payload.js"; -export { createMeetupDraft, createMeetupDraftFromFile } from "./create-event.js"; +export { + createMeetupDraft, + createMeetupDraftFromFile, + createMeetupDrafts, +} from "./create-event.js"; export type { CreateMeetupDraftFromFileOptions, CreateMeetupDraftFromFileResult, CreateMeetupDraftOptions, CreateMeetupDraftResult, + CreateMeetupDraftsOptions, + CreateMeetupDraftsResult, } from "./create-event.js"; export { fetchAllVenues, formatVenueKey, listVenues } from "./list-venues.js"; export type { ListVenuesOptions, MeetupVenue } from "./list-venues.js"; -export { DEFAULT_CONFIG_FILENAME, loadMeetupConfig, resolveHostIds } from "./config.js"; -export type { CoopkitConfig, MeetupConfig } from "./config.js"; +export { + DEFAULT_CONFIG_FILENAME, + loadMeetupConfig, + resolveGroupTargets, + resolveHostIds, +} from "./config.js"; +export type { CoopkitConfig, GroupTarget, MeetupConfig } from "./config.js";