From 11f6a452ee2d4796ad9786b2ad3ce6c9ad171262 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Sat, 15 Aug 2026 22:28:23 +0900 Subject: Add /api/band-import endpoint for bulk band creation via JSON Co-Authored-By: Claude Sonnet 4.6 --- app/routes/api-band-import.tsx | 173 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 app/routes/api-band-import.tsx (limited to 'app/routes/api-band-import.tsx') diff --git a/app/routes/api-band-import.tsx b/app/routes/api-band-import.tsx new file mode 100644 index 0000000..6c0bc9f --- /dev/null +++ b/app/routes/api-band-import.tsx @@ -0,0 +1,173 @@ +import type { ActionFunctionArgs } from "react-router"; +import { + createArtist, + createBand, + getArtistBySlug, + getBandBySlug, + getIpAddress, + toSlug, + type MemberInput, +} from "~/lib/db.server"; + +interface LinkInput { + label: string; + url: string; +} + +interface ArtistImport { + name: string; + links?: LinkInput[]; +} + +interface MemberImport { + name: string; + role?: string | null; + since?: string; + until?: string; + note?: string; +} + +interface BandImport { + name: string; + slug?: string; + area?: string | null; + description?: string | null; + status?: string; + links?: LinkInput[]; + members?: MemberImport[]; + message?: string; +} + +interface ImportPayload { + artists?: ArtistImport[]; + bands: BandImport[]; +} + +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + return Response.json({ error: "Method not allowed" }, { status: 405 }); + } + + let payload: ImportPayload; + try { + payload = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!payload || !Array.isArray(payload.bands) || payload.bands.length === 0) { + return Response.json({ error: "`bands` must be a non-empty array" }, { status: 400 }); + } + + const ip = getIpAddress(request); + const artistIdByName = new Map(); + const artistsCreated: { id: string; name: string }[] = []; + const artistsReused: { id: string; name: string }[] = []; + const bandsCreated: { id: string; name: string; slug: string }[] = []; + const bandsSkipped: { name: string; reason: string }[] = []; + const errors: string[] = []; + + // resolves (creating if necessary) an artist by name; reuses an existing + // artist when its derived slug already exists so re-running an import is idempotent + function resolveArtist(name: string, links: LinkInput[] = []): string | null { + const cached = artistIdByName.get(name); + if (cached) return cached; + + const baseSlug = toSlug(name); + const existing = baseSlug ? getArtistBySlug(baseSlug) : null; + if (existing) { + artistIdByName.set(name, existing.id); + artistsReused.push({ id: existing.id, name: existing.name }); + return existing.id; + } + + const fallbackBase = baseSlug || crypto.randomUUID().slice(0, 8); + for (let attempt = 0; attempt < 3; attempt++) { + const id = crypto.randomUUID(); + const slug = attempt === 0 ? fallbackBase : `${fallbackBase}-${id.slice(0, 6)}`; + try { + createArtist({ id, slug, name, links, message: "JSONインポートで自動作成", ip_address: ip }); + artistIdByName.set(name, id); + artistsCreated.push({ id, name }); + return id; + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed")) continue; + errors.push(`アーティスト「${name}」の作成に失敗: ${e instanceof Error ? e.message : "不明なエラー"}`); + return null; + } + } + errors.push(`アーティスト「${name}」の作成に失敗: slugの衝突が解消しませんでした`); + return null; + } + + for (const a of payload.artists ?? []) { + if (a?.name) resolveArtist(a.name, a.links ?? []); + } + + for (const b of payload.bands) { + if (!b?.name) { + errors.push("バンド名が空のエントリをスキップしました"); + continue; + } + + const baseSlug = b.slug ? toSlug(b.slug) : toSlug(b.name); + if (baseSlug && getBandBySlug(baseSlug)) { + bandsSkipped.push({ name: b.name, reason: `slug "${baseSlug}" は既に使用されています` }); + continue; + } + + const members: MemberInput[] = []; + for (const m of b.members ?? []) { + if (!m?.name) continue; + const artistId = resolveArtist(m.name); + if (!artistId) continue; + members.push({ + artist_id: artistId, + role: m.role ?? null, + since: m.since ?? "", + until: m.until ?? "", + note: m.note ?? "", + }); + } + + const fallbackBase = baseSlug || crypto.randomUUID().slice(0, 8); + let stop = false; + for (let attempt = 0; attempt < 3 && !stop; attempt++) { + const id = crypto.randomUUID(); + const slug = attempt === 0 ? fallbackBase : `${fallbackBase}-${id.slice(0, 6)}`; + try { + createBand({ + id, + slug, + name: b.name, + area: b.area ?? null, + description: b.description ?? null, + status: b.status ?? "active", + links: b.links ?? [], + members, + message: b.message ?? "JSONインポートで自動登録", + ip_address: ip, + }); + bandsCreated.push({ id, name: b.name, slug }); + stop = true; + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed")) continue; + errors.push(`バンド「${b.name}」の作成に失敗: ${e instanceof Error ? e.message : "不明なエラー"}`); + stop = true; + } + } + } + + return Response.json({ + ok: errors.length === 0, + artistsCreated, + artistsReused, + bandsCreated, + bandsSkipped, + errors, + }); +} + +export default function () { + return null; +} -- cgit v1.2.3