{"openapi":"3.0.3","info":{"title":"Calendrier Fur API","description":"Liste de conventions furry dans le monde entier — scrapées sur furryguides.com, furrycons.com et consurf.net, dédupliquées puis normalisées dans un seul format. Les routes `/api/conventions`, `/api/calendar` et `/api/status` sont publiques et lisibles en CORS depuis les domaines autorisés (voir `lib/cors.ts`). Les routes de gestion (création/édition/suppression, résolution de conflits) sont réservées au staff : elles exigent à la fois un token Cognito valide (groupe `gestionconvsfur`) et un `Origin` correspondant exactement au dashboard de gestion — un token seul ne suffit pas.","version":"1.1.0"},"servers":[{"url":"/","description":"Déploiement courant"}],"tags":[{"name":"conventions","description":"Lecture publique + gestion staff des conventions"},{"name":"calendar","description":"Export calendrier .ics"},{"name":"status","description":"Métadonnées / valeurs de filtres disponibles"},{"name":"scrape","description":"Rafraîchissement des données depuis les sources"},{"name":"enrich","description":"Enrichissement des infos d'inscription/billetterie"},{"name":"conflicts","description":"Staff — arbitrage des valeurs en désaccord avec les sites officiels"}],"paths":{"/api/conventions":{"get":{"tags":["conventions"],"summary":"Liste des conventions (JSON)","description":"Retourne les conventions au format JSON, filtrables et paginables. Accessible en CORS depuis les domaines autorisés.","parameters":[{"name":"continent","in":"query","schema":{"type":"string"},"description":"Filtre par continent (ex: Europe, North America, Asia, Oceania, South America, Africa)"},{"name":"country","in":"query","schema":{"type":"string"},"description":"Filtre par pays (nom complet ou code, insensible à la casse)"},{"name":"year","in":"query","schema":{"type":"string"},"description":"Filtre par année de début (ex: 2026)"},{"name":"future","in":"query","schema":{"type":"string","enum":["0","1"],"default":"1"},"description":"Si différent de 0, ne retourne que les événements à venir"},{"name":"q","in":"query","schema":{"type":"string"},"description":"Recherche floue sur le nom, la ville ou le pays"},{"name":"limit","in":"query","schema":{"type":"integer"},"description":"Nombre maximum de résultats"},{"name":"offset","in":"query","schema":{"type":"integer","default":0},"description":"Décalage de pagination"}],"responses":{"200":{"description":"Liste des conventions","content":{"application/json":{"schema":{"type":"object","properties":{"meta":{"type":"object","properties":{"total":{"type":"integer"},"filtered":{"type":"integer"},"offset":{"type":"integer"},"updatedAt":{"type":"string","format":"date-time","nullable":true}}},"conventions":{"type":"array","items":{"$ref":"#/components/schemas/Convention"}}}}}}}}},"post":{"tags":["conventions"],"summary":"Crée une convention manuellement (staff)","description":"Staff uniquement — origine dashboard + Bearer Cognito requis (voir `security` ci-dessous). La convention est enregistrée avec `source: \"manual\"` et ne sera jamais écrasée par un scrape ultérieur. 409 si une convention avec le même nom+date de début existe déjà.","security":[{"staffAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"startDate":{"type":"string","format":"date"},"endDate":{"type":"string","format":"date"},"city":{"type":"string"},"country":{"type":"string"},"url":{"type":"string","format":"uri"},"description":{"type":"string"},"continent":{"type":"string","description":"Déduit du pays si omis"},"location":{"type":"string","description":"Déduit de city+country si omis"},"address":{"type":"string"},"attendance":{"type":"string"},"website":{"type":"string","format":"uri"},"price":{"type":"string"},"registrationUrl":{"type":"string","format":"uri"},"registrationStatus":{"type":"string","enum":["unknown","open","closed","announced"]},"registrationOpenDate":{"type":"string","format":"date"}},"required":["name","startDate","endDate","city","country","url","description"]}}}},"responses":{"201":{"description":"Convention créée","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"convention":{"$ref":"#/components/schemas/Convention"}}}}}},"400":{"description":"Champs requis manquants","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Token manquant ou invalide","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Origine non autorisée ou compte hors groupe staff","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Une convention avec ce nom et cette date de début existe déjà","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/conventions/{id}":{"get":{"tags":["conventions"],"summary":"Convention unique par identifiant stable","description":"Retourne une seule convention à partir de son `id` stable (slug du nom + date de début). 404 si non trouvée.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Identifiant stable, ex: furrydelphia-2026-08-06"}],"responses":{"200":{"description":"Convention trouvée","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Convention"}}}},"404":{"description":"Convention non trouvée","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"put":{"tags":["conventions"],"summary":"Modifie une convention (staff)","description":"Staff uniquement. Patch partiel : seuls les champs présents dans le corps sont modifiés. Un champ effectivement modifié parmi `name/startDate/endDate/city/country/continent/url/description/address/price/registrationOpenDate` est marqué `manualFields` — il ne sera plus jamais écrasé par un futur scrape (voir `lib/dedup.ts`). Modifier `name` ou `startDate` recalcule l'`id`. Toute résolution de conflit en attente sur un champ modifié ici est automatiquement purgée.","security":[{"staffAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Sous-ensemble partiel des champs de Convention"}}}},"responses":{"200":{"description":"Convention mise à jour","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"convention":{"$ref":"#/components/schemas/Convention"}}}}}},"400":{"description":"Champs requis manquants après application du patch","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Token manquant ou invalide","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Origine non autorisée ou compte hors groupe staff","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Convention non trouvée","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["conventions"],"summary":"Supprime une convention (staff)","description":"Staff uniquement. Purge aussi les éventuels conflits en attente liés à cette convention.","security":[{"staffAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Convention supprimée","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}}}}}},"401":{"description":"Token manquant ou invalide","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Origine non autorisée ou compte hors groupe staff","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Convention non trouvée","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/calendar":{"get":{"tags":["calendar"],"summary":"Export du calendrier au format iCalendar (.ics)","description":"Retourne un fichier .ics téléchargeable, filtrable comme /api/conventions (sans pagination).","parameters":[{"name":"continent","in":"query","schema":{"type":"string"}},{"name":"country","in":"query","schema":{"type":"string"}},{"name":"year","in":"query","schema":{"type":"string"}},{"name":"future","in":"query","schema":{"type":"string","enum":["0","1"],"default":"1"},"description":"Si différent de 0, ne retourne que les événements à venir"}],"responses":{"200":{"description":"Fichier iCalendar","content":{"text/calendar":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/status":{"get":{"tags":["status"],"summary":"Métadonnées et valeurs de filtres disponibles","description":"Retourne la date de dernière mise à jour, le nombre total de conventions, et les valeurs distinctes de continent/pays/année, ainsi que toutes les conventions.","responses":{"200":{"description":"État courant des données","content":{"application/json":{"schema":{"type":"object","properties":{"updatedAt":{"type":"string","format":"date-time","nullable":true},"total":{"type":"integer"},"continents":{"type":"array","items":{"type":"string"}},"countries":{"type":"array","items":{"type":"string"}},"years":{"type":"array","items":{"type":"string"}},"conventions":{"type":"array","items":{"$ref":"#/components/schemas/Convention"}}}}}}}}}},"/api/scrape":{"get":{"tags":["scrape"],"summary":"Déclenche (ou vérifie) le scraping des sources","description":"Relance le scraping de toutes les sources (furrycons.com, furryguides.com, consurf.net) et met à jour le stockage. Tourne automatiquement toutes les 24h via Vercel Cron (non soumis au cooldown ci-dessous) ; les appels utilisateur/API sont limités à une fois toutes les 3h sauf `force=true`. GET et POST sont équivalents.","parameters":[{"name":"force","in":"query","schema":{"type":"boolean","default":false},"description":"Ignore le cooldown de 3h et force un nouveau scrape"}],"responses":{"200":{"description":"Résultat du scrape (ou skip si data fraîche)","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"total":{"type":"integer"},"changed":{"type":"boolean"},"skipped":{"type":"boolean"},"reason":{"type":"string"},"nextScrapeAt":{"type":"string","format":"date-time"},"errors":{"type":"array","items":{"type":"string"}}}}}}},"500":{"description":"Erreur lors du scraping","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"error":{"type":"string"}}}}}}}},"post":{"tags":["scrape"],"summary":"Alias POST de GET /api/scrape","responses":{"200":{"description":"Voir GET /api/scrape"}}}},"/api/enrich":{"get":{"tags":["enrich"],"summary":"Enrichit les conventions à venir avec leurs infos d'inscription","description":"Visite le site officiel (`website` ou `url`) de chaque convention à venir candidate et tente d'en extraire un lien d'inscription/billetterie, un statut, une date d'ouverture et un logo (best-effort, dégrade vers `unknown` en cas d'échec). Traite un lot borné par appel ; un cooldown de 7 jours par convention s'applique sauf `force=true`.","parameters":[{"name":"id","in":"query","schema":{"type":"string"},"description":"Restreint l'enrichissement à une seule convention (ignore le cooldown)"},{"name":"force","in":"query","schema":{"type":"boolean","default":false},"description":"Ignore le cooldown de 7 jours par convention"}],"responses":{"200":{"description":"Résultat de l'enrichissement","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"checked":{"type":"integer"},"updated":{"type":"integer"},"remaining":{"type":"integer"},"errors":{"type":"array","items":{"type":"object"}}}}}}}}},"post":{"tags":["enrich"],"summary":"Alias POST de GET /api/enrich","responses":{"200":{"description":"Voir GET /api/enrich"}}}},"/api/conflicts":{"get":{"tags":["conflicts"],"summary":"Liste les conflits en attente (staff)","description":"Un conflit apparaît quand un scrape trouve, pour `startDate`/`endDate`/`price`, une valeur différente de celle déjà stockée — au lieu d'écraser silencieusement, l'ancienne et la nouvelle valeur sont mises en attente d'arbitrage staff (voir `lib/conflicts.ts`). Réservé au dashboard, ne fait pas partie de la surface publique.","security":[{"staffAuth":[]}],"responses":{"200":{"description":"Conflits en attente","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"count":{"type":"integer"},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/ConventionConflict"}}}}}}},"401":{"description":"Token manquant ou invalide","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Origine non autorisée ou compte hors groupe staff","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["conflicts"],"summary":"Résout un conflit (staff)","description":"`accept` applique la valeur proposée par la source, `reject` garde la valeur actuelle, `override` applique une valeur saisie à la main (`value` requis) quand aucune des deux propositions n'est correcte. Dans tous les cas le conflit est retiré de la file une fois traité.","security":[{"staffAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"conventionId":{"type":"string"},"field":{"type":"string","enum":["startDate","endDate","price"]},"action":{"type":"string","enum":["accept","reject","override"]},"value":{"type":"string","description":"Requis uniquement pour action=override"}},"required":["conventionId","field","action"]}}}},"responses":{"200":{"description":"Conflit résolu","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"resolvedBy":{"type":"string","description":"Nom d'utilisateur Cognito du staff ayant tranché"},"action":{"type":"string"}}}}}},"400":{"description":"Champs requis manquants (ou value absent pour override)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Token manquant ou invalide","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Origine non autorisée ou compte hors groupe staff","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Convention ou conflit introuvable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}},"components":{"securitySchemes":{"staffAuth":{"type":"http","scheme":"bearer","bearerFormat":"Cognito ID token","description":"Token d'identité Cognito du groupe `gestionconvsfur`. Insuffisant à lui seul : l'API exige en plus un header `Origin` égal à https://convsfurs.nexsite.fr, donc ces routes ne sont utilisables que depuis le dashboard de gestion, jamais via un client tiers muni d'un token valide."}},"schemas":{"Convention":{"type":"object","properties":{"id":{"type":"string","description":"Identifiant stable (slug du nom + date de début), généré côté API","example":"furrydelphia-2026-08-06"},"name":{"type":"string"},"startDate":{"type":"string","format":"date","example":"2026-08-14"},"endDate":{"type":"string","format":"date","example":"2026-08-18"},"location":{"type":"string","description":"Libellé complet du lieu"},"url":{"type":"string","format":"uri"},"description":{"type":"string"},"country":{"type":"string"},"city":{"type":"string"},"continent":{"type":"string"},"source":{"type":"string"},"address":{"type":"string","nullable":true},"attendance":{"type":"string","nullable":true,"example":"6.7k"},"website":{"type":"string","nullable":true,"format":"uri"},"registrationUrl":{"type":"string","nullable":true,"format":"uri","description":"Lien d'inscription/billetterie détecté (best-effort)"},"registrationStatus":{"type":"string","nullable":true,"enum":["unknown","open","closed","announced"],"description":"Statut d'inscription détecté (best-effort, non garanti)"},"registrationOpenDate":{"type":"string","format":"date","nullable":true,"description":"Date d'ouverture des inscriptions détectée (best-effort)"},"registrationCheckedAt":{"type":"string","format":"date-time","nullable":true,"description":"Dernière tentative d'enrichissement des infos d'inscription"},"logoUrl":{"type":"string","nullable":true,"format":"uri","description":"Logo/image (best-effort, og:image)"},"price":{"type":"string","nullable":true,"description":"Prix du billet détecté (best-effort), ex: \"$120\" ou \"€90 – €150\""},"manualFields":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Champs corrigés à la main par le staff — ne seront plus jamais écrasés par un scrape ultérieur"}},"required":["name","startDate","endDate","location","url","description","country","city","continent","source"]},"ConventionConflict":{"type":"object","properties":{"id":{"type":"string","description":"`{conventionId}:{field}`","example":"furrydelphia-2026-08-06:startDate"},"conventionId":{"type":"string"},"conventionName":{"type":"string"},"field":{"type":"string","enum":["startDate","endDate","price"]},"currentValue":{"type":"string","nullable":true,"description":"Valeur actuellement stockée"},"proposedValue":{"type":"string","description":"Nouvelle valeur trouvée lors du dernier scrape"},"source":{"type":"string","description":"Source à l'origine de la valeur proposée"},"sourceUrl":{"type":"string","nullable":true,"format":"uri","description":"Page où la valeur proposée a été trouvée"},"detectedAt":{"type":"string","format":"date-time"}},"required":["id","conventionId","conventionName","field","proposedValue","source","detectedAt"]},"ErrorResponse":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"error":{"type":"string"}},"required":["ok","error"]}}}}