{"openapi":"3.0.3","info":{"title":"DramŠok Archive API","version":"1.2.0","termsOfService":"https://archive.dramshok.art/api-terms","contact":{"name":"VšĮ ArtLaboratorija \"DramŠok\"","email":"work@dramshok.art","url":"https://archive.dramshok.art/data-rights"},"description":"## Быстрый старт для приложений\n\nAPI 1.2: выбор профиля и PIN без общего пароля. Cookie, WebView,\ndeviceToken и предварительный вызов auth/archive новым приложениям не нужны.\nВсе старые методы API 1.0 продолжают работать.\n\n### Вход одним запросом\n\n`POST /api/v1/auth/login` принимает только числовой `profileId` и строковый `pin`.\nОбщий пароль не нужен. PIN передавайте строкой: `\"0123\"`, а не числом.\nДля профиля без PIN администратор должен сначала задать его на сайте архива.\n\n```json\n{\"profileId\":1,\"pin\":\"0123\",\"tokenName\":\"iPhone\"}\n```\n\nОтвет: `data.accessToken`, `data.expiresAt`, `data.expiresIn`, `data.profile`,\n`data.accentColor`, `data.onAccentColor`, `data.links`. Значения YOUR_* и EXAMPLE_*\nв примерах вымышлены и не дают доступа. Номер профиля 1 тоже только пример.\n\n### Если пользователь должен выбрать профиль\n\n1. `GET /api/v1/profiles` без заголовков авторизации и без cookie.\n2. Покажите `data.profiles`: `id`, `name`, `avatarUrl`, `hasPin`, `maintenanceBlocked`.\n3. После выбора запросите PIN и вызовите `POST /api/v1/auth/login` с `{ \"profileId\": 1, \"pin\": \"0123\" }`.\n\nСписок профилей публичный, но предметы доступны только после проверки PIN.\nПустой список возвращает 200 и `profiles: []`. При `hasPin: false` предложите\nобратиться к администратору; при `maintenanceBlocked: true` вход временно закрыт.\nОтвет также содержит `accentColor`, `onAccentColor` и `maintenance`.\nВременный loginToken не нужен. Старый discover оставлен для совместимости.\nОбщий лимит методов\nвхода: 30 запросов за 10 минут на IP. При 429 учитывайте заголовок Retry-After.\n\n### Дальше только Bearer\n\n### Первичный PIN и обязательная смена\n\nАдминистратор может назначить первичный (временный) PIN или заменить его постоянным.\nПосле login проверяйте `data.profile.mustChangePin`. Если true, откройте обязательную\nформу смены PIN вместо архива. На сайте эта форма появляется автоматически и не\nзакрывается без смены или выхода. На Swift-экране обрабатывайте этот флаг до перехода\nв каталог; при восстановлении входа проверяйте его также через `/me`.\n\n`POST /api/v1/profile/change-pin` с Bearer:\n`{\"currentPin\":\"0123\",\"newPin\":\"5678\"}`. Новый PIN: строка из 4–8 цифр, отличается\nот текущего. При успехе сохраните `data.accessToken` вместо прежнего, затем открывайте\nкаталог. `data.mustChangePin` станет false. Старые токены и запомненные входы отозваны.\nДо смены предметы недоступны: `403 PIN_CHANGE_REQUIRED`. При 409 войдите заново:\nадминистратор или другое устройство уже изменили PIN.\n\nНазначение через админку не раскрывает прежний PIN. Постоянный PIN, назначенный\nадминистратором, не требует обязательной смены. Назначение любого нового PIN\nотзывает существующие входы этого профиля.\n\n### Авторизованные запросы\n\nВременные профили используют те же методы входа и API. Запрещённые действия\nвозвращают `403 PERMISSION_DENIED`. Администратор отдельно разрешает просмотр,\nдобавление, редактирование существующих и удаление созданных самим профилем предметов.\nРедактирование и удаление своих по умолчанию отключены. Чужие предметы нельзя удалять\nдаже после их редактирования. Смена собственного PIN и аватара запрещена всегда.\nПо истечении срока профиль удаляется, сессии и токены перестают работать (401).\nСнимки созданных предметов и журнал остаются в админке после удаления профиля.\nСуществующие методы и форматы запросов не меняются; данные профиля дополнены\n`isTemporary`, `expiresAt` и `permissions`. Клиенты могут учитывать их для интерфейса,\nно сервер проверяет права самостоятельно.\n\nВ админке доступны выборочное восстановление отсутствующих предметов из снимков\nи отмена создания, если предмет не изменён после снимка. Правки сохраняют состояние\nдо и после изменения, удаления — состояние перед удалением. Восстановление правки\nвозможно, если текущее состояние совпадает с её результатом; чужие последующие\nизменения не затираются. Несколько снимков обрабатываются от новых к старым.\n«Восстановить всё» обрабатывает все снимки. Выборочное восстановление применяется\nк показанным снимкам (до 500); журнал действий загружается\nстраницами по 100 записей. Фото снимков защищены от штатного удаления файлов.\n\nПередавайте `Authorization: Bearer <accessToken>` в запросах данных. Успех:\n`{ \"ok\": true, \"data\": {...}, \"meta\": {...} }`. Поле meta необязательное.\nОшибка: `{ \"ok\": false, \"error\": { \"code\": \"...\", \"message\": \"...\", \"status\": 401 } }`.\nПроверяйте HTTP-статус до разбора data. Коды ошибок стабильны; message предназначен пользователю.\n\nТокен действует 180 дней до expiresAt, если раньше не отозван, не сброшен PIN и\nне удалён профиль. Храните его в iOS Keychain / Android Keystore. Не передавайте\nсекреты в query string, логи и публичные репозитории. В браузере используйте\n`credentials: 'omit'`; CORS разрешает Bearer-запросы, не междоменные cookie.\n\n### Все айтемы\n\n### Поиск\n\n`GET /api/v1/items?q=черная%20шляпа` ищет по названию, тегам, категории,\nинвентарному номеру, назначению, описанию и характеристикам. Регистр, ё/е и\nпорядок слов не важны: `шляпа черная` и `черная шляпа` дают те же совпадения.\nВсе слова должны встретиться, но могут находиться в разных полях: `шляпа бархат`.\nИнвентарные номера допускают пробелы и дефисы или их отсутствие; числовой ID\nтоже можно найти. Точные номера и названия показываются выше совпадений в описании.\nДопускается одна опечатка на слово из четырёх и более букв: пропуск, лишняя буква,\nзамена или перестановка соседних букв. Например, `деревяный` найдёт `деревянный`\nи наоборот, даже если ошибка в данных. Точные совпадения идут выше приблизительных.\nДля коротких слов, чисел и инвентарных номеров нечёткое сопоставление отключено.\nТекст самих предметов не изменяется. Фильтры section/status/category/itemType\nприменяются до поиска, limit/offset — после ранжирования. Если предмет не находится,\nпроверьте выбранный раздел и состояние. Сайт использует тот же механизм поиска.\n\n### Полный список\n\n`GET /api/v1/items/all` с Bearer возвращает ВСЕ предметы во всех разделах.\nНет скрытого ограничения 100, пагинации и фильтрации. Порядок: числовой id по\nвозрастанию. Каждый айтем содержит полные поля и creator. Фотографии представлены\nабсолютными URL, а не бинарными данными. История изменений запрашивается отдельно.\n\nОтвет: `data.items` и `meta: {total, hasMore: false, generatedAt}`. total равно\nдлине массива. Пустой архив возвращает 200 и `[]`. Любые query-параметры, включая\nlimit и offset, дадут 400: для поиска, фильтров и пагинации оставлен `GET /api/v1/items`.\nДоступ и ограничения техработ такие же, как у остальных методов предметов.\n\nДля офлайн-каталога заменяйте локальную копию только после полного успешного\nответа. Удаляйте локальные ID, отсутствующие в новом снимке. Обновляйте каталог\nпри запуске, возвращении сети или вручную, не каждую секунду. Снимок отражает\nсостояние на момент запроса, а не резервирует предметы от изменений.\n\n### AccentColor и конфигурация\n\n`GET /api/v1/config` возвращает параметры API, ссылки на методы и оформление.\n`GET /api/v1/accent-color` возвращает только оформление. Короткий адрес:\n[API AccentColor](https://archive.dramshok.art/api/AccentColor). Чтение публичное.\n\n```json\n{\"ok\":true,\"data\":{\"accentColor\":\"#AEF3FF\",\"onAccentColor\":\"#000000\",\"updatedAt\":null}}\n```\n\naccentColor всегда в формате #RRGGBB. onAccentColor выбирает чёрный или белый цвет\nтекста с подходящим контрастом. updatedAt равен null, пока цвет ни разу не сохраняли.\nНастройка меняется на сайте в админке, сохраняется в базе и применяется на всех\nустройствах. Приложение должно перечитать её при открытии/возврате на экран.\nПри отсутствии сети используйте последний сохранённый цвет. ETag/If-None-Match\nпозволяют получить 304 без тела, если цвет не изменился.\n\n### Продление и выход\n\nДо истечения срока вызовите `POST /api/v1/auth/refresh` с действующим Bearer.\nЭто замена accessToken, а не отдельный OAuth refresh_token. Старый токен сразу\nотзывается, новый снова действует 180 дней. Обновляйте сохранённый токен атомарно\nи выполняйте только один refresh одновременно. Истёкший токен продлить нельзя.\nПри тайм-ауте после ротации может потребоваться login: сервер мог уже отозвать старый токен.\n\n`POST /api/v1/auth/logout` с Bearer отзывает текущий токен. После успешного ответа\nудалите его из хранилища. При 401 покажите вход, при 423 экран техработ, при 429\nподождите, при 5xx/сетевой ошибке сохраните токен и предложите повторить запрос.\n\n### Готовый JavaScript-клиент\n\n[archive-client.js](https://archive.dramshok.art/archive-client.js) — ES-модуль без зависимостей.\nХранение токена остаётся под контролем приложения, localStorage не используется.\n\n```javascript\nimport { ArchiveClient } from 'https://archive.dramshok.art/archive-client.js';\nconst archive = new ArchiveClient();\nconst choice = await archive.profiles();\n// Покажите choice.profiles и запросите PIN выбранного профиля.\nconst session = await archive.login({profileId: selectedId, pin});\n// Сохраните session.accessToken в защищённом хранилище своей платформы.\nconst items = await archive.allItems();\nconst theme = await archive.accentColor();\nconsole.log(items.length, theme.accentColor);\n// На следующем запуске: new ArchiveClient({token: savedToken}).\n// Для фильтров: await archive.items({section:'костюмы', limit:50, offset:0}).\n// При выходе: await archive.logout().\n```\n\n### iOS / Swift\n\n#### Блокировка ИИ организатором\n\nАдминистратор может запустить исправление названий и поисковых тегов. Пока идёт\nрезервное копирование, обработка или проверка черновиков, все запросы к предметам\nи генерации инвентарных номеров возвращают HTTP **423**:\n\n```json\n{\"ok\":false,\"error\":{\"status\":423,\"code\":\"ORGANIZER_BUSY\",\"message\":\"ИИ организатор исправляет архив. Попробуйте позже, пожалуйста. (97%)\",\"details\":{\"progress\":97}}}\n```\n\nНе удаляйте токен и не переводите пользователя на экран входа. Скройте ранее\nоткрытые предметы, покажите `error.message`. Проверяйте\n`GET /api/organizer/status` каждые несколько секунд. Ответ без авторизации:\n`{\"active\":true,\"progress\":97,\"status\":\"running\",\"message\":\"...\"}`.\nПри `active:false` снова загрузите предметы. Событие SSE `organizer_changed`\nсодержит те же поля. Уже загруженные клиентом данные сервер отозвать не может:\nприложение должно самостоятельно скрывать кеш во время блокировки.\n\nОрганизатор находится в админ-панели сайта. Можно выбрать предметы из списка,\nпо точным инвентарным ID (каждый с новой строки), номерам базы или весь архив.\nМодели: `deepseek-ai/DeepSeek-V4-Flash` (по умолчанию), `openai/gpt-oss-20b`,\n`openai/gpt-oss-120b`. Ключ DeepInfra вводится в защищённой админ-панели и не\nвозвращается через API. Во внешний API отправляются только номер, название и теги,\nпакетами не более 20. Фотографии, профили и PIN не отправляются.\n\nПеред отправкой создаётся ZIP с согласованной копией SQLite и каталогом uploads.\nЧерновики хранятся отдельно: рабочие предметы не меняются до нажатия «Опубликовать».\nАдминистратор может изменить предложения, снять «Применить», вернуть оригинал,\nили отменить весь запуск. Отмена не требует восстановления базы, потому что\nисходные предметы остаются нетронутыми. Публикация атомарная и проверяет конфликты.\nZIP содержит служебные данные базы: передавайте его только доверенным администраторам.\n\nСостояние и результаты каждого пакета сохраняются на сервере. После перезапуска\nобработка продолжается с первого неподтверждённого пакета (он может повторно\nпотребовать токены DeepInfra). После ошибки доступны повтор пакета или отмена.\nВ состоянии `review` и `failed` архив остаётся закрытым до решения администратора.\nРезервные копии доступны в разделе «Резервные копии ZIP» и автоматически не удаляются.\n\n#### Цвет состояния предмета\n\nВ `/api/v1` поле `conditionMeta.textColor` содержит имя Color Set из\n`Assets.xcassets`, а не HEX. Соответствие одинаково для списка, всех предметов,\nполной карточки, похожих предметов и `/api/v1/meta`:\n\n| Состояние | textColor |\n| --- | --- |\n| новый | conditionNew |\n| хорошее | conditionGood |\n| требует ремонта | conditionRepair |\n| списан | conditionWrittenOff |\n\nСоздайте в приложении четыре Color Set с этими точными именами (регистр важен).\nЗадайте цвета для Any Appearance и при необходимости Dark. Сервер выбирает имя\nпо состоянию предмета; само значение цвета задаётся в Assets приложения.\nНеизвестное состояние использует `conditionGood`.\n\nПример фрагмента ответа:\n\n```json\n{\"conditionLabel\":\"хорошее\",\"conditionMeta\":{\"color\":\"#1f7a40\",\"textColor\":\"conditionGood\"}}\n```\n\n```swift\nText(item.conditionLabel)\n    .foregroundStyle(Color(item.conditionMeta.textColor))\n```\n\nВ Codable-модели `textColor` остаётся `String`. Поле `conditionMeta.color`\nсохраняет прежний HEX. Новых полей и параметров запросов нет. Внутренний API\nвеб-сайта сохраняет прежние CSS-цвета.\n\nПервый экран: загрузка → список профилей → форма PIN → основной экран.\n`AsyncImage` загружает `avatarUrl`; при null используйте инициалы или системную иконку.\nНе показывайте форму общего пароля. Пример SwiftUI (iOS 15+) ниже получает список\nбез cookie и возвращает токен в `onLogin`. Сохраните его в Keychain перед переходом\nв основной экран. В примере код хранения секретов намеренно не подменён UserDefaults.\n\n```swift\nimport SwiftUI\n\nstruct ArchiveProfile: Decodable, Identifiable, Hashable {\n    let id: Int\n    let name: String\n    let avatarUrl: URL?\n    let hasPin: Bool\n    let maintenanceBlocked: Bool\n}\nstruct ProfileListData: Decodable { let profiles: [ArchiveProfile] }\nstruct LoginProfileState: Decodable { let mustChangePin: Bool }\nstruct ProfileLoginData: Decodable { let accessToken: String; let profile: LoginProfileState }\nstruct ProfileReply<T: Decodable>: Decodable { let data: T }\nstruct ProfileLoginBody: Encodable { let profileId: Int; let pin: String }\n\nstruct ArchiveProfilesScreen: View {\n    let onLogin: (String, Bool) -> Void // token, mustChangePin\n    @State private var profiles: [ArchiveProfile] = []\n    @State private var selected: ArchiveProfile?\n    @State private var pin = \"\"\n    @State private var busy = false\n    @State private var message = \"\"\n\n    var body: some View {\n        NavigationView {\n            List {\n                if busy { ProgressView() }\n                if !message.isEmpty { Text(message).foregroundColor(.red) }\n                if let profile = selected {\n                    Text(profile.name).font(.headline)\n                    SecureField(\"PIN\", text: $pin).keyboardType(.numberPad)\n                    Button(\"Войти\") { Task { await login(profile) } }\n                        .disabled(busy || pin.isEmpty)\n                    Button(\"Другой профиль\") { selected = nil; pin = \"\"; message = \"\" }\n                        .disabled(busy)\n                } else {\n                    ForEach(profiles) { profile in\n                        Button {\n                            selected = profile; pin = \"\"; message = \"\"\n                        } label: {\n                            HStack {\n                                AsyncImage(url: profile.avatarUrl) { image in\n                                    image.resizable().scaledToFill()\n                                } placeholder: { Image(systemName: \"person.crop.circle\") }\n                                .frame(width: 40, height: 40).clipShape(Circle())\n                                Text(profile.name)\n                                if !profile.hasPin { Text(\"PIN не задан\").font(.caption) }\n                                if profile.maintenanceBlocked { Text(\"Недоступен\").font(.caption) }\n                            }\n                        }.disabled(busy || !profile.hasPin || profile.maintenanceBlocked)\n                    }\n                    if profiles.isEmpty && !busy { Text(\"Нет доступных профилей\") }\n                    Button(\"Обновить\") { Task { await loadProfiles() } }.disabled(busy)\n                }\n            }.navigationTitle(\"Выберите профиль\")\n        }.task { await loadProfiles() }\n    }\n\n    @MainActor private func loadProfiles() async {\n        busy = true; message = \"\"\n        defer { busy = false }\n        do {\n            var request = URLRequest(url: URL(string: \"https://archive.dramshok.art/api/v1/profiles\")!)\n            request.httpShouldHandleCookies = false\n            request.timeoutInterval = 30\n            let (data, response) = try await URLSession.shared.data(for: request)\n            guard (response as? HTTPURLResponse)?.statusCode == 200 else {\n                throw URLError(.badServerResponse)\n            }\n            profiles = try JSONDecoder().decode(ProfileReply<ProfileListData>.self, from: data).data.profiles\n        } catch { message = \"Не удалось загрузить профили. Повторите попытку.\" }\n    }\n\n    @MainActor private func login(_ profile: ArchiveProfile) async {\n        busy = true; message = \"\"\n        defer { busy = false }\n        do {\n            var request = URLRequest(url: URL(string: \"https://archive.dramshok.art/api/v1/auth/login\")!)\n            request.httpMethod = \"POST\"\n            request.httpShouldHandleCookies = false\n            request.timeoutInterval = 30\n            request.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")\n            request.httpBody = try JSONEncoder().encode(ProfileLoginBody(profileId: profile.id, pin: pin))\n            let (data, response) = try await URLSession.shared.data(for: request)\n            guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) }\n            guard http.statusCode == 200 else {\n                switch http.statusCode {\n                case 401: message = \"Неверный PIN\"\n                case 429: message = \"Подождите \\(http.value(forHTTPHeaderField: \"Retry-After\") ?? \"60\") сек. перед повтором\"\n                case 423: message = \"Технические работы\"\n                case 409: message = \"Обратитесь к администратору для настройки PIN\"\n                default: message = \"Не удалось войти. Обновите список профилей и повторите.\"\n                }\n                return\n            }\n            let session = try JSONDecoder().decode(ProfileReply<ProfileLoginData>.self, from: data).data\n            pin = \"\"\n            onLogin(session.accessToken, session.profile.mustChangePin)\n        } catch { message = \"Ошибка подключения. Повторите попытку.\" }\n    }\n}\n```\n\nПосле следующего запуска проверьте сохранённый токен через `GET /api/v1/me`.\nПри 200 открывайте архив сразу; при 401 удалите токен и покажите этот экран.\nПри отсутствии сети токен не удаляйте. Для защиты от многократных отправок\nкнопки заблокированы во время запроса; при 429 не повторяйте запрос автоматически.\n\nМинимальный запрос входа без SwiftUI:\n\n```swift\nstruct Login: Encodable { let profileId: Int; let pin: String }\nstruct Session: Decodable { let accessToken: String; let tokenType: String; let expiresAt: String }\nstruct Envelope<T: Decodable>: Decodable { let ok: Bool; let data: T }\nvar request = URLRequest(url: URL(string: \"https://archive.dramshok.art/api/v1/auth/login\")!)\nrequest.httpMethod = \"POST\"\nrequest.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")\nrequest.httpBody = try JSONEncoder().encode(Login(profileId: selectedId, pin: pin))\nlet (data, response) = try await URLSession.shared.data(for: request)\nguard let http = response as? HTTPURLResponse, http.statusCode == 200 else {\n    // В интерфейсе разберите error.code и error.message из JSON.\n    throw URLError(.userAuthenticationRequired)\n}\nlet session = try JSONDecoder().decode(Envelope<Session>.self, from: data).data\n// Сохраните session.accessToken в Keychain.\nvar all = URLRequest(url: URL(string: \"https://archive.dramshok.art/api/v1/items/all\")!)\nall.setValue(\"Bearer \" + session.accessToken, forHTTPHeaderField: \"Authorization\")\nlet (itemsData, itemsResponse) = try await URLSession.shared.data(for: all)\n// Проверьте статус, затем декодируйте data.items по схеме ItemFull ниже.\n```\n\n### Карточка по номеру из QR: Swift / iOS\n\nНомер в ссылке `https://archive.dramshok.art/?item=123` — первичный ключ `items.id`.\nОн совпадает с `id` в списке API, но не с инвентарным номером предмета.\nДля открытия карточки не требуется скачивать весь архив:\n\n```bash\ncurl 'https://archive.dramshok.art/api/v1/items/123' \\\n  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'\n```\n\nОтвет содержит `data.item` (полная карточка по схеме ItemFull, включая поля\nи URL фотографий) и `data.similar` (массив похожих предметов).\n`400 INVALID_ITEM_ID` означает неправильный номер, `404 ITEM_NOT_FOUND` —\nпредмет удалён или не существует. При `401` нужен повторный вход;\nпри `423` покажите сообщение о технических работах. Сетевые ошибки и `5xx`\nне должны удалять сохранённый токен.\n\nНиже самостоятельный минимальный клиент на Foundation. Модель декодирует только\nID и название; добавляйте остальные поля по схеме ItemFull. Чужие домены,\nнеоднозначные ссылки с несколькими `item` и дробные ID отклоняются.\nТокен отправляется только на фиксированный адрес API, не на URL из QR.\n\n```swift\nimport Foundation\n\nenum ArchiveScanError: Error {\n    case invalidQR\n    case http(Int)\n}\nstruct ScannedItem: Decodable { let id: Int; let name: String }\nstruct ScannedCard: Decodable {\n    let item: ScannedItem\n    let similar: [ScannedItem]\n}\nstruct CardEnvelope: Decodable { let ok: Bool; let data: ScannedCard }\n\nfunc archiveItemID(from scanned: String) throws -> Int {\n    let value = scanned.trimmingCharacters(in: .whitespacesAndNewlines)\n    let raw: String\n    if value.range(of: \"^[1-9][0-9]*$\", options: .regularExpression) != nil {\n        raw = value\n    } else {\n        guard let url = URLComponents(string: value),\n              url.scheme?.lowercased() == \"https\",\n              url.host?.lowercased() == \"archive.dramshok.art\",\n              url.port == nil || url.port == 443,\n              url.user == nil, url.password == nil,\n              let matches = url.queryItems?.filter({ $0.name == \"item\" }),\n              matches.count == 1, let number = matches.first?.value else {\n            throw ArchiveScanError.invalidQR\n        }\n        raw = number\n    }\n    guard raw.range(of: \"^[1-9][0-9]*$\", options: .regularExpression) != nil,\n          let id = Int(raw), id <= 9_007_199_254_740_991 else {\n        throw ArchiveScanError.invalidQR\n    }\n    return id\n}\n\nfunc loadScannedCard(_ scanned: String, token: String) async throws -> ScannedCard {\n    let id = try archiveItemID(from: scanned)\n    let url = URL(string: \"https://archive.dramshok.art/api/v1/items/\\(id)\")!\n    var request = URLRequest(url: url)\n    request.timeoutInterval = 30\n    request.httpShouldHandleCookies = false\n    request.setValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"Accept\")\n    let (data, response) = try await URLSession.shared.data(for: request)\n    guard let http = response as? HTTPURLResponse else {\n        throw URLError(.badServerResponse)\n    }\n    guard http.statusCode == 200 else { throw ArchiveScanError.http(http.statusCode) }\n    return try JSONDecoder().decode(CardEnvelope.self, from: data).data\n}\n// В обработчике сканера:\n// let card = try await loadScannedCard(qrText, token: tokenFromKeychain)\n// Откройте экран card.item; обновление UI выполняйте на MainActor.\n```\n\nПока идёт запрос, приостановите сканер или игнорируйте повтор того же QR,\nчтобы не открывать несколько экранов. После входа повторите запрос сохранённого\nID. При 404 оставьте возможность сканировать следующий код.\n\nДля генерации типизированного клиента используйте [OpenAPI JSON](https://archive.dramshok.art/openapi.json).\nНиже для каждого нового маршрута есть схема запроса, ответа, ошибки и примеры вызова.\n"},"servers":[{"url":"https://archive.dramshok.art/api/v1"}],"tags":[{"name":"System","description":"Проверка доступности, справочники и состояние сессии. Эти ручки удобно дергать при старте приложения и перед показом основного интерфейса."},{"name":"Auth","description":"Выберите профиль через GET /profiles и войдите по PIN через POST /auth/login. Общий пароль, cookie и временный токен не нужны."},{"name":"Profiles","description":"Профили пользователей архива. Профиль определяет, от чьего имени создаются и изменяются элементы, а также хранит настройки фильтров."},{"name":"Items","description":"Предметы архива: костюмы, реквизит и склад. Для списка используйте компактные карточки, для экрана деталей - отдельную ручку элемента."},{"name":"Inventory","description":"Инвентарные номера и вспомогательные операции. Клиент может попросить следующий ID, но сервер все равно проверяет уникальность при сохранении."},{"name":"Appearance","description":"Единый акцентный цвет сайта и приложений. Публичное чтение; изменение только администратором сайта."}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"DramŠok archive API token"},"adminSession":{"type":"apiKey","in":"cookie","name":"connect.sid","description":"Подтверждённая письмом сессия администратора сайта. Обычный Bearer-токен профиля не даёт права изменять оформление."}},"parameters":{"ItemId":{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1},"description":"ID элемента архива."},"Limit":{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":100},"description":"Размер страницы."},"Offset":{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Смещение для пагинации."}},"schemas":{"ErrorEnvelope":{"type":"object","required":["ok","error"],"properties":{"ok":{"type":"boolean","enum":[false]},"error":{"$ref":"#/components/schemas/ApiError"}}},"ApiError":{"type":"object","required":["code","message","status"],"properties":{"code":{"type":"string","examples":["ARCHIVE_LOCKED","PROFILE_REQUIRED","ITEM_NOT_FOUND","MAINTENANCE"]},"message":{"type":"string"},"status":{"type":"integer"},"details":{}}},"SuccessEnvelope":{"type":"object","required":["ok","data"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"object"},"meta":{"type":"object"}}},"FileAsset":{"type":"object","description":"`url` используйте для отображения картинки в приложении. `path` храните только если нужно потом передать это фото в `keepPhotos` при редактировании.","nullable":true,"properties":{"path":{"type":"string","example":"/uploads/items/example.jpg"},"url":{"type":"string","format":"uri","example":"https://archive.dramshok.art/uploads/items/example.jpg"}}},"ConditionMeta":{"type":"object","properties":{"color":{"type":"string","example":"#1f7a40"},"textColor":{"type":"string","enum":["conditionNew","conditionGood","conditionRepair","conditionWrittenOff"],"example":"conditionGood","description":"Имя Color Set в Assets.xcassets для SwiftUI Color(item.conditionMeta.textColor). Не HEX. Создайте conditionNew, conditionGood, conditionRepair и conditionWrittenOff в приложении."}}},"Maintenance":{"type":"object","required":["enabled","allowedProfileIds"],"properties":{"enabled":{"type":"boolean"},"allowedProfileIds":{"type":"array","items":{"type":"integer"}}}},"Profile":{"type":"object","description":"Пользовательский профиль архива. Все создания и изменения элементов привязываются к текущему профилю.","required":["id","name","hasPin","autoLogin"],"properties":{"id":{"type":"integer","example":1},"name":{"type":"string","example":"Платон"},"avatarPath":{"type":"string","nullable":true,"example":"/uploads/avatars/platon.jpg"},"avatarUrl":{"type":"string","nullable":true,"format":"uri"},"hasPin":{"type":"boolean"},"autoLogin":{"type":"boolean"},"pinVersion":{"type":"integer"},"preferences":{"$ref":"#/components/schemas/ProfilePreferences"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"maintenanceBlocked":{"type":"boolean"},"mustChangePin":{"type":"boolean","description":"Временный PIN: перед доступом к архиву требуется смена."},"isTemporary":{"type":"boolean","description":"Ограниченный временный профиль."},"expiresAt":{"type":"string","format":"date-time","nullable":true},"permissions":{"type":"object","required":["read","create","edit","delete","deleteOwn","changePin","changeAvatar"],"properties":{"read":{"type":"boolean"},"create":{"type":"boolean"},"edit":{"type":"boolean"},"delete":{"type":"boolean"},"deleteOwn":{"type":"boolean"},"changePin":{"type":"boolean"},"changeAvatar":{"type":"boolean"}}}}},"ProfilePreferences":{"type":"object","description":"Последние настройки фильтров профиля. Можно использовать как стартовое состояние интерфейса.","properties":{"section":{"type":"string","enum":["all","костюмы","реквизит","склад"]},"search":{"type":"string"},"status":{"type":"string","enum":["all","новый","хорошее","требует ремонта","списан"]}}},"ItemInput":{"type":"object","description":"Тело запроса для создания/редактирования предмета. Для `PATCH` можно отправлять только изменившиеся поля; сервер подставит текущие значения остальных полей.","required":["name","section","conditionLabel"],"properties":{"name":{"type":"string","example":"Черная шляпа","description":"Название предмета, обязательное поле."},"section":{"type":"string","enum":["костюмы","реквизит","склад"],"description":"Главный раздел архива."},"category":{"type":"string","example":"шляпы","description":"Свободная категория. Для склада принимает только поддерживаемые складские категории."},"itemType":{"type":"string","description":"Тип предмета. Для `костюмы` и `реквизит` обязателен и влияет на букву инвентарного ID. Для `склад` можно не передавать: сервис сохранит `склад`.","examples":["одежда","головной убор","реквизит"]},"searchTags":{"type":"string","description":"Теги для поиска через запятую или пробел."},"completeness":{"type":"string","description":"Комплектность: например `пиджак + брюки`, `1 шт.`, `нет пуговиц`."},"size":{"type":"string","description":"Размер. Для реквизита и склада сервер очищает это поле."},"gender":{"type":"string","description":"Пол/тип посадки. Для реквизита и склада сервер очищает это поле."},"color":{"type":"string","description":"Основной цвет."},"material":{"type":"string","description":"Материал."},"conditionLabel":{"type":"string","enum":["новый","хорошее","требует ремонта","списан"],"description":"Состояние предмета. Обязательное поле."},"arrivalDate":{"type":"string","format":"date","description":"Дата поступления. Если не передать, сервер поставит текущую дату."},"inventoryId":{"type":"string","description":"Если не передать, сервис подберет следующий ID автоматически."},"keepPhotos":{"type":"array","description":"При multipart update: список уже существующих фото, которые нужно оставить.","items":{"type":"string"}},"purpose":{"type":"string","description":"Назначение: спектакль, проект, хранение, аренда и т.д."},"fundingSource":{"type":"string","description":"Источник финансирования или покупки."},"notes":{"type":"string","description":"Внутренние заметки."}}},"ItemCard":{"type":"object","description":"Компактная карточка для списков, поиска и плитки. Для полного экрана деталей используйте `GET /items/{id}`.","required":["id","name","section","itemType","conditionLabel","inventoryId"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"section":{"type":"string"},"category":{"type":"string"},"itemType":{"type":"string"},"searchTags":{"type":"string"},"комплектность":{"type":"string"},"purpose":{"type":"string"},"conditionLabel":{"type":"string"},"inventoryId":{"type":"string"},"firstPhoto":{"$ref":"#/components/schemas/FileAsset"},"photos":{"type":"array","items":{"$ref":"#/components/schemas/FileAsset"}},"photosCount":{"type":"integer"},"conditionMeta":{"$ref":"#/components/schemas/ConditionMeta"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"links":{"$ref":"#/components/schemas/ItemLinks"}}},"ItemFull":{"description":"Полная карточка предмета для экрана деталей.","allOf":[{"$ref":"#/components/schemas/ItemCard"},{"type":"object","properties":{"size":{"type":"string"},"gender":{"type":"string"},"color":{"type":"string"},"material":{"type":"string"},"arrivalDate":{"type":"string"},"fundingSource":{"type":"string"},"notes":{"type":"string"},"creator":{"type":"object","nullable":true,"properties":{"id":{"type":"integer"},"name":{"type":"string"},"avatarPath":{"type":"string","nullable":true},"avatarUrl":{"type":"string","nullable":true,"format":"uri"}}}}}]},"ItemLinks":{"type":"object","properties":{"self":{"type":"string","example":"/api/v1/items/123"},"history":{"type":"string","example":"/api/v1/items/123/history"},"qr":{"type":"string","example":"/api/v1/items/123/qr"}}},"HistoryEntry":{"type":"object","description":"Запись истории: кто и когда создал/изменил предмет, плюс список измененных полей.","properties":{"id":{"type":"integer"},"action":{"type":"string","examples":["create","update","delete"]},"summary":{"type":"string"},"createdAt":{"type":"string"},"profile":{"type":"object","nullable":true,"properties":{"name":{"type":"string"},"avatarPath":{"type":"string","nullable":true},"avatarUrl":{"type":"string","nullable":true,"format":"uri"}}},"details":{"type":"array","items":{"type":"object"}}}},"ArchiveAuthRequest":{"type":"object","description":"Открывает архив общим паролем и создает cookie-сессию. Для мобильных приложений это первый шаг перед выбором профиля.","required":["password"],"properties":{"password":{"type":"string","format":"password"}}},"ProfileLoginRequest":{"type":"object","description":"Вход в конкретный профиль. Если `issueToken=true`, сервер вернет bearer token для мобильного приложения.","required":["profileId","pin"],"properties":{"profileId":{"type":"integer"},"pin":{"type":"string","example":"1234"},"deviceToken":{"type":"string","description":"Стабильный ID устройства для авто-входа."},"issueToken":{"type":"boolean","default":true},"tokenName":{"type":"string","example":"iPhone 15 Pro"}}},"ProfileAutoLoginRequest":{"type":"object","description":"Авто-вход для уже известного устройства. Работает только пока PIN профиля не сброшен администратором.","required":["profileId","deviceToken"],"properties":{"profileId":{"type":"integer"},"deviceToken":{"type":"string"},"issueToken":{"type":"boolean","default":true},"tokenName":{"type":"string"}}},"Appearance":{"type":"object","required":["accentColor","onAccentColor","updatedAt"],"properties":{"accentColor":{"type":"string","pattern":"^#[0-9A-Fa-f]{6}$"},"onAccentColor":{"type":"string","enum":["#000000","#FFFFFF"]},"updatedAt":{"type":"string","format":"date-time","nullable":true}}},"AppSession":{"type":"object","required":["accessToken","tokenType","expiresAt","expiresIn","profile","accentColor","onAccentColor","updatedAt","links"],"properties":{"accessToken":{"type":"string"},"tokenType":{"type":"string","enum":["Bearer"]},"expiresAt":{"type":"string","format":"date-time"},"expiresIn":{"type":"integer"},"profile":{"$ref":"#/components/schemas/Profile"},"accentColor":{"type":"string","pattern":"^#[0-9A-Fa-f]{6}$"},"onAccentColor":{"type":"string","enum":["#000000","#FFFFFF"]},"updatedAt":{"type":"string","format":"date-time","nullable":true},"links":{"type":"object","additionalProperties":{"type":"string","format":"uri"}}}},"AppLogin":{"type":"object","required":["profileId","pin"],"properties":{"profileId":{"type":"integer","minimum":1},"pin":{"type":"string","format":"password","maxLength":64},"tokenName":{"type":"string","maxLength":80}}}},"responses":{"Unauthorized":{"description":"Нет доступа.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"examples":{"default":{"$ref":"#/components/examples/UnauthorizedError"}}}}},"NotFound":{"description":"Объект не найден.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"examples":{"default":{"summary":"Не найдено","value":{"ok":false,"error":{"code":"ITEM_NOT_FOUND","message":"Элемент не найден.","status":404}}}}}}},"BadRequest":{"description":"Ошибка валидации.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"examples":{"default":{"$ref":"#/components/examples/ValidationError"}}}}},"Maintenance":{"description":"Технические работы.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"examples":{"default":{"summary":"Техработы","value":{"ok":false,"error":{"code":"MAINTENANCE","message":"Сейчас проходят технические работы.","status":423,"details":{"maintenance":true}}}}}}}}},"examples":{"HealthResponse":{"summary":"API работает","value":{"ok":true,"data":{"service":"archive","status":"ok","at":"2026-08-28T17:50:00.000Z","maintenance":{"enabled":false,"allowedProfileIds":[]}}}},"ErrorLocked":{"summary":"Архив закрыт","value":{"ok":false,"error":{"code":"ARCHIVE_LOCKED","message":"Нужно открыть архив паролем.","status":401}}},"ValidationError":{"summary":"Ошибка валидации","value":{"ok":false,"error":{"code":"ITEM_CREATE_FAILED","message":"Нужно указать наименование.","status":400}}},"UnauthorizedError":{"summary":"Нет bearer token","value":{"ok":false,"error":{"code":"PROFILE_REQUIRED","message":"Нужно войти в профиль.","status":401}}},"SessionResponse":{"summary":"Состояние сессии","value":{"ok":true,"data":{"archiveUnlocked":true,"profile":{"id":1,"name":"Платон","avatarPath":"/uploads/avatars/platon.jpg","avatarUrl":"https://archive.dramshok.art/uploads/avatars/platon.jpg","hasPin":true,"autoLogin":false,"pinVersion":1,"preferences":{"section":"all","search":"","status":"all"}},"authMode":"bearer","adminApproved":false,"maintenance":{"enabled":false,"allowedProfileIds":[]},"maintenanceAllowed":true}}},"MetaResponse":{"summary":"Справочники","value":{"ok":true,"data":{"sections":["костюмы","реквизит","склад"],"types":["реквизит","головной убор","украшения","одежда","обувь"],"storageCategories":["нитки","ткани","инструменты","другие элементы"],"conditionMeta":{"новый":{"color":"#A6F48A","textColor":"conditionNew"},"хорошее":{"color":"#1f7a40","textColor":"conditionGood"},"требует ремонта":{"color":"#ffe16a","textColor":"conditionRepair"},"списан":{"color":"#ff6a6a","textColor":"conditionWrittenOff"}},"maintenance":{"enabled":false,"allowedProfileIds":[]}}}},"ArchiveAuthRequest":{"summary":"Открыть архив","value":{"password":"archive-password"}},"ArchiveAuthResponse":{"summary":"Архив открыт","value":{"ok":true,"data":{"archiveUnlocked":true}}},"ProfileLoginRequest":{"summary":"Вход с выдачей токена","value":{"profileId":1,"pin":"1234","deviceToken":"ios-device-uuid","issueToken":true,"tokenName":"iPhone"}},"ProfileLoginResponse":{"summary":"Bearer token для приложения","value":{"ok":true,"data":{"profile":{"id":1,"name":"Платон","avatarPath":"/uploads/avatars/platon.jpg","avatarUrl":"https://archive.dramshok.art/uploads/avatars/platon.jpg","hasPin":true,"autoLogin":true,"pinVersion":1,"preferences":{"section":"all","search":"","status":"all"},"createdAt":"2026-04-25 10:20:00","updatedAt":"2026-04-25 10:20:00"},"accessToken":"dsa_exampleBearerTokenForMobileApp","tokenType":"Bearer","expiresAt":"2027-02-24T17:50:00.000Z"}}},"ProfilesResponse":{"summary":"Список профилей","value":{"ok":true,"data":{"profiles":[{"id":1,"name":"Платон","avatarPath":"/uploads/avatars/platon.jpg","avatarUrl":"https://archive.dramshok.art/uploads/avatars/platon.jpg","hasPin":true,"autoLogin":true,"pinVersion":1,"preferences":{"section":"all","search":"","status":"all"},"maintenanceBlocked":false}],"maintenance":{"enabled":false,"allowedProfileIds":[]}}}},"ItemsResponse":{"summary":"Страница элементов","value":{"ok":true,"data":{"items":[{"id":123,"name":"Черная шляпа","section":"костюмы","category":"шляпы","itemType":"головной убор","комплектность":"1 шт.","purpose":"Спектакль","conditionLabel":"хорошее","inventoryId":"C00 123","firstPhoto":{"path":"/uploads/items/hat.jpg","url":"https://archive.dramshok.art/uploads/items/hat.jpg"},"photos":[{"path":"/uploads/items/hat.jpg","url":"https://archive.dramshok.art/uploads/items/hat.jpg"}],"photosCount":1,"conditionMeta":{"color":"#1f7a40","textColor":"conditionGood"},"createdAt":"2026-04-25 10:20:00","updatedAt":"2026-04-25 10:20:00","links":{"self":"/api/v1/items/123","history":"/api/v1/items/123/history","qr":"/api/v1/items/123/qr"}}]},"meta":{"total":1,"limit":50,"offset":0,"hasMore":false}}},"ItemResponse":{"summary":"Карточка элемента","value":{"ok":true,"data":{"item":{"id":123,"name":"Черная шляпа","section":"костюмы","category":"шляпы","itemType":"головной убор","searchTags":"черная, сцена","комплектность":"1 шт.","size":"M","gender":"унисекс","color":"черный","material":"фетр","conditionLabel":"хорошее","arrivalDate":"2026-04-25","inventoryId":"C00 123","photos":[{"path":"/uploads/items/hat.jpg","url":"https://archive.dramshok.art/uploads/items/hat.jpg"}],"purpose":"Спектакль","fundingSource":"DramŠok","notes":"Хранить в коробке","createdAt":"2026-04-25 10:20:00","updatedAt":"2026-04-25 10:20:00","creator":{"id":1,"name":"Платон","avatarPath":"/uploads/avatars/platon.jpg","avatarUrl":"https://archive.dramshok.art/uploads/avatars/platon.jpg"}},"similar":[]}}},"CreateItemRequest":{"summary":"Создать предмет без фото","value":{"name":"Черная шляпа","section":"костюмы","category":"шляпы","itemType":"головной убор","conditionLabel":"хорошее","color":"черный","material":"фетр","purpose":"Спектакль","notes":"Хранить в коробке"}},"PatchItemRequest":{"summary":"Изменить только состояние и заметку","value":{"conditionLabel":"требует ремонта","notes":"Проверить крепление"}},"MutatedItemResponse":{"summary":"Элемент сохранен","value":{"ok":true,"data":{"id":123,"item":{"id":123,"name":"Черная шляпа","section":"костюмы","category":"шляпы","itemType":"головной убор","conditionLabel":"хорошее","inventoryId":"C00 123","firstPhoto":null,"photos":[],"photosCount":0,"conditionMeta":{"color":"#1f7a40","textColor":"conditionGood"},"links":{"self":"/api/v1/items/123","history":"/api/v1/items/123/history","qr":"/api/v1/items/123/qr"}}}}},"HistoryResponse":{"summary":"История изменений","value":{"ok":true,"data":{"history":[{"id":900,"action":"update","summary":"Изменён элемент «Черная шляпа».","createdAt":"2026-04-25 11:20:00","profile":{"name":"Платон","avatarPath":"/uploads/avatars/platon.jpg","avatarUrl":"https://archive.dramshok.art/uploads/avatars/platon.jpg"},"details":[{"field":"Состояние","before":"хорошее","after":"требует ремонта"}]}]}}},"QrResponse":{"summary":"QR-код","value":{"ok":true,"data":{"url":"https://archive.dramshok.art/?item=123","dataUrl":"data:image/png;base64,iVBORw0KGgo..."}}},"NextIdResponse":{"summary":"Следующий инвентарный ID","value":{"ok":true,"data":{"inventoryId":"A00 124"}}},"DeleteResponse":{"summary":"Удаление","value":{"ok":true,"data":{"deleted":true,"id":123}}}}},"paths":{"/config":{"get":{"tags":["System"],"operationId":"getAppConfig","summary":"Конфигурация для старта приложения","security":[],"responses":{"200":{"description":"Успешный ответ","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"object","required":["accentColor","onAccentColor","updatedAt","apiVersion","auth","links"],"properties":{"accentColor":{"type":"string","pattern":"^#[0-9A-Fa-f]{6}$"},"onAccentColor":{"type":"string","enum":["#000000","#FFFFFF"]},"updatedAt":{"type":"string","format":"date-time","nullable":true},"apiVersion":{"type":"string"},"auth":{"type":"object","required":["type","cookieRequired","loginFields","tokenLifetimeSeconds"],"properties":{"type":{"type":"string"},"cookieRequired":{"type":"boolean"},"loginFields":{"type":"array","items":{"type":"string"}},"tokenLifetimeSeconds":{"type":"integer"}}},"links":{"type":"object","additionalProperties":{"type":"string","format":"uri"}}}}}},"example":{"ok":true,"data":{"accentColor":"#AEF3FF","onAccentColor":"#000000","updatedAt":null,"apiVersion":"1.2.0","auth":{"type":"bearer","cookieRequired":false,"loginFields":["profileId","pin"],"tokenLifetimeSeconds":15552000},"links":{"login":"https://archive.dramshok.art/api/v1/auth/login","allItems":"https://archive.dramshok.art/api/v1/items/all"}}}}}}}}},"/auth/discover":{"post":{"tags":["Auth"],"operationId":"discoverProfiles","summary":"Выбрать профиль без cookie","security":[],"description":"Совместимость со старыми приложениями. Общий пароль больше не проверяется. Новым приложениям используйте GET /profiles → POST /auth/login (profileId + pin).","responses":{"200":{"description":"Успешный ответ","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"object","required":["loginToken","expiresAt","expiresIn","profiles","accentColor","onAccentColor","updatedAt"],"properties":{"loginToken":{"type":"string"},"expiresAt":{"type":"string","format":"date-time"},"expiresIn":{"type":"integer"},"profiles":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/Profile"},{"type":"object","required":["maintenanceBlocked"],"properties":{"maintenanceBlocked":{"type":"boolean"}}}]}},"accentColor":{"type":"string","pattern":"^#[0-9A-Fa-f]{6}$"},"onAccentColor":{"type":"string","enum":["#000000","#FFFFFF"]},"updatedAt":{"type":"string","format":"date-time","nullable":true}}}}},"example":{"ok":true,"data":{"loginToken":"dsl_EXAMPLE_NOT_A_REAL_TOKEN","expiresIn":600,"expiresAt":"2026-09-11T12:10:00.000Z","profiles":[{"id":1,"name":"Пример профиля","avatarPath":null,"avatarUrl":null,"hasPin":true,"autoLoginAvailable":false,"maintenanceBlocked":false}],"accentColor":"#AEF3FF","onAccentColor":"#000000","updatedAt":null}}}}},"429":{"description":"Слишком много попыток входа.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":429,"code":"RATE_LIMITED","message":"Слишком много попыток входа."}}}},"headers":{"Retry-After":{"description":"Через сколько секунд повторить запрос","schema":{"type":"integer"}}}}},"deprecated":true}},"/auth/login":{"post":{"tags":["Auth"],"operationId":"loginToArchive","summary":"Войти одним запросом и получить токен","security":[],"description":"Вход по profileId и PIN. Предварительно вызовите публичный GET /profiles для выбора профиля. PIN передавайте строкой. Возвращает Bearer-токен, профиль, оформление и ссылки. PIN_NOT_SET: обратитесь к администратору. Неправильный PIN: 401 INVALID_PIN. При 429 учитывайте Retry-After.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppLogin"},"example":{"profileId":1,"pin":"0123","tokenName":"iPhone"}}}},"responses":{"200":{"description":"Успешный ответ","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/AppSession"}}},"example":{"ok":true,"data":{"accessToken":"dsa_EXAMPLE_NOT_A_REAL_TOKEN","tokenType":"Bearer","expiresAt":"2027-03-10T12:00:00.000Z","expiresIn":15552000,"profile":{"id":1,"name":"Пример профиля","avatarPath":null,"avatarUrl":null,"hasPin":true,"autoLoginAvailable":false},"accentColor":"#AEF3FF","onAccentColor":"#000000","updatedAt":null,"links":{"allItems":"https://archive.dramshok.art/api/v1/items/all","me":"https://archive.dramshok.art/api/v1/me","docs":"https://archive.dramshok.art/docs"}}}}}},"400":{"description":"Проверьте поля запроса.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":400,"code":"INVALID_LOGIN_INPUT","message":"Проверьте поля запроса."}}}}},"401":{"description":"Неверный PIN или недействительный токен.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":401,"code":"INVALID_PIN","message":"Неверный PIN или недействительный токен."}}}}},"404":{"description":"Профиль не найден.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":404,"code":"PROFILE_NOT_FOUND","message":"Профиль не найден."}}}}},"409":{"description":"Задайте PIN на сайте архива.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":409,"code":"PIN_NOT_SET","message":"Задайте PIN на сайте архива."}}}}},"423":{"description":"Сейчас проходят технические работы.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":423,"code":"MAINTENANCE","message":"Сейчас проходят технические работы."}}}}},"429":{"description":"Слишком много попыток входа.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":429,"code":"RATE_LIMITED","message":"Слишком много попыток входа."}}}},"headers":{"Retry-After":{"description":"Через сколько секунд повторить запрос","schema":{"type":"integer"}}}}},"x-codeSamples":[{"lang":"curl","source":"curl 'https://archive.dramshok.art/api/v1/auth/login' -H 'Content-Type: application/json' -d '{\"profileId\":1,\"pin\":\"0123\",\"tokenName\":\"iPhone\"}'"},{"lang":"JavaScript","source":"const profiles = await archive.profiles();\nconst session = await archive.login({profileId: selectedId, pin});"}]}},"/auth/refresh":{"post":{"tags":["Auth"],"operationId":"rotateAccessToken","summary":"Заменить действующий токен новым","security":[{"bearerAuth":[]}],"description":"Требует действующий Bearer, не cookie. Новый токен действует 180 дней, старый сразу отзывается. Отдельного refresh_token нет. Выполняйте только один refresh одновременно. Истёкший/отозванный токен требует login.","responses":{"200":{"description":"Успешный ответ","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/AppSession"}}},"example":{"ok":true,"data":{"accessToken":"dsa_EXAMPLE_NOT_A_REAL_TOKEN","tokenType":"Bearer","expiresAt":"2027-03-10T12:00:00.000Z","expiresIn":15552000,"profile":{"id":1,"name":"Пример профиля","avatarPath":null,"avatarUrl":null,"hasPin":true,"autoLoginAvailable":false},"accentColor":"#AEF3FF","onAccentColor":"#000000","updatedAt":null,"links":{"allItems":"https://archive.dramshok.art/api/v1/items/all","me":"https://archive.dramshok.art/api/v1/me","docs":"https://archive.dramshok.art/docs"}}}}}},"401":{"description":"Неверный PIN или недействительный токен.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":401,"code":"INVALID_PIN","message":"Неверный PIN или недействительный токен."}}}}},"423":{"description":"Сейчас проходят технические работы.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":423,"code":"MAINTENANCE","message":"Сейчас проходят технические работы."}}}}}},"x-codeSamples":[{"lang":"curl","source":"curl -X POST 'https://archive.dramshok.art/api/v1/auth/refresh' -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'"}]}},"/items/all":{"get":{"tags":["Items"],"operationId":"getAllItems","summary":"Получить ВСЕ айтемы с полными полями","security":[{"bearerAuth":[]}],"description":"Все разделы, без лимита 100 и без пагинации, порядок id ASC. Фотографии представлены URL, история запрашивается отдельно. Любые query-параметры отклоняются; для поиска и фильтров используйте /items.","responses":{"200":{"description":"Успешный ответ","content":{"application/json":{"schema":{"type":"object","required":["ok","data","meta"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ItemFull"}}}},"meta":{"type":"object","required":["total","hasMore","generatedAt"],"properties":{"total":{"type":"integer"},"hasMore":{"type":"boolean","enum":[false]},"generatedAt":{"type":"string","format":"date-time"}}}}},"example":{"ok":true,"data":{"items":[]},"meta":{"total":0,"hasMore":false,"generatedAt":"2026-09-11T12:00:00.000Z"}}}}},"400":{"description":"Для фильтров используйте /items.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":400,"code":"UNSUPPORTED_QUERY","message":"Для фильтров используйте /items."}}}}},"401":{"description":"Неверный PIN или недействительный токен.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":401,"code":"INVALID_PIN","message":"Неверный PIN или недействительный токен."}}}}},"423":{"description":"Сейчас проходят технические работы.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":423,"code":"MAINTENANCE","message":"Сейчас проходят технические работы."}}}}}},"x-codeSamples":[{"lang":"curl","source":"curl 'https://archive.dramshok.art/api/v1/items/all' -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'"},{"lang":"JavaScript","source":"const r = await fetch('https://archive.dramshok.art/api/v1/items/all', {credentials:'omit', headers:{Authorization: 'Bearer ' + token}});\nconst result = await r.json();\nif (!r.ok) throw new Error(result.error.message);\nconst items = result.data.items;\nconsole.log(items.length, result.meta.total);"}]}},"/accent-color":{"get":{"tags":["Appearance"],"summary":"Получить актуальный AccentColor","operationId":"getAccentColor","security":[],"description":"Публичное чтение. Цвет сохраняется в базе и меняется из админ-панели. Поддерживает If-None-Match: при 304 используйте локальный кэш.","responses":{"200":{"description":"Успешный ответ","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/Appearance"}}},"example":{"ok":true,"data":{"accentColor":"#AEF3FF","onAccentColor":"#000000","updatedAt":null}}}}},"304":{"description":"Цвет не изменился, тело ответа отсутствует."}},"x-codeSamples":[{"lang":"curl","source":"curl 'https://archive.dramshok.art/api/v1/accent-color'"}]}},"/AccentColor":{"servers":[{"url":"https://archive.dramshok.art/api"}],"get":{"tags":["Appearance"],"summary":"Короткий URL API AccentColor","operationId":"getAccentColorAlias","security":[],"description":"Публичное чтение. Цвет сохраняется в базе и меняется из админ-панели. Поддерживает If-None-Match: при 304 используйте локальный кэш.","responses":{"200":{"description":"Успешный ответ","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/Appearance"}}},"example":{"ok":true,"data":{"accentColor":"#AEF3FF","onAccentColor":"#000000","updatedAt":null}}}}},"304":{"description":"Цвет не изменился, тело ответа отсутствует."}},"x-codeSamples":[{"lang":"curl","source":"curl 'https://archive.dramshok.art/api/v1/accent-color'"}]}},"/admin/appearance":{"servers":[{"url":"https://archive.dramshok.art/api"}],"get":{"tags":["Appearance"],"operationId":"getAdminAppearance","summary":"Настройка оформления в админке","security":[{"adminSession":[]}],"responses":{"200":{"description":"Успешный ответ","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/Appearance"}}},"example":{"ok":true,"data":{"accentColor":"#AEF3FF","onAccentColor":"#000000","updatedAt":null}}}}},"401":{"description":"Неверный PIN или недействительный токен.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":401,"code":"INVALID_PIN","message":"Неверный PIN или недействительный токен."}}}}}}},"patch":{"tags":["Appearance"],"operationId":"updateAccentColor","summary":"Изменить акцентный цвет","description":"Только подтверждённая сессия администратора сайта и корректный Origin. Токен обычного профиля не подходит. Формат #RRGGBB. Сохраняется в SQLite; сайт получает событие appearance_changed.","security":[{"adminSession":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accentColor"],"properties":{"accentColor":{"type":"string","pattern":"^#[0-9A-Fa-f]{6}$"}}},"example":{"accentColor":"#087F8C"}}}},"responses":{"200":{"description":"Успешный ответ","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/Appearance"}}},"example":{"ok":true,"data":{"accentColor":"#087F8C","onAccentColor":"#FFFFFF","updatedAt":"2026-09-11T12:00:00.000Z"}}}}},"400":{"description":"Укажите цвет #RRGGBB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":400,"code":"INVALID_ACCENT_COLOR","message":"Укажите цвет #RRGGBB."}}}}},"401":{"description":"Неверный PIN или недействительный токен.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":401,"code":"INVALID_PIN","message":"Неверный PIN или недействительный токен."}}}}},"403":{"description":"Недопустимый источник запроса.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":403,"code":"INVALID_ORIGIN","message":"Недопустимый источник запроса."}}}}}}}},"/health":{"get":{"tags":["System"],"summary":"Проверить доступность API","security":[],"responses":{"200":{"description":"API работает.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/HealthResponse"}}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -sS https://archive.dramshok.art/api/v1/health"}]}},"/meta":{"get":{"tags":["System"],"summary":"Получить справочники архива","description":"Возвращает разделы, типы предметов, складские категории, цвета состояний и статус техработ.","security":[],"responses":{"200":{"description":"Справочники архива.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/MetaResponse"}}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -sS https://archive.dramshok.art/api/v1/meta"},{"lang":"Swift","label":"URLSession","source":"let url = URL(string: \"https://archive.dramshok.art/api/v1/meta\")!\nlet (data, _) = try await URLSession.shared.data(from: url)"}]}},"/auth/archive":{"post":{"tags":["Auth"],"summary":"Открыть архив паролем","description":"Совместимость со старыми приложениями. Общий пароль больше не проверяется. Новым приложениям используйте GET /profiles → POST /auth/login (profileId + pin).","security":[],"responses":{"200":{"description":"Архив открыт.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/ArchiveAuthResponse"}}}}}},"deprecated":true}},"/auth/profile":{"post":{"tags":["Auth"],"summary":"Войти в профиль и получить bearer token","description":"Основной метод авторизации для iOS/Android. Возвращает `accessToken`, `tokenType: Bearer` и дату истечения.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileLoginRequest"},"examples":{"default":{"$ref":"#/components/examples/ProfileLoginRequest"}}}}},"responses":{"200":{"description":"Вход выполнен.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/ProfileLoginResponse"}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"423":{"$ref":"#/components/responses/Maintenance"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X POST https://archive.dramshok.art/api/v1/auth/profile \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"profileId\":1,\"pin\":\"1234\",\"deviceToken\":\"ios-device-uuid\",\"issueToken\":true,\"tokenName\":\"iPhone\"}'"},{"lang":"Swift","label":"URLSession","source":"struct LoginBody: Encodable {\n  let profileId: Int\n  let pin: String\n  let deviceToken: String\n  let issueToken: Bool\n  let tokenName: String\n}\n\nvar request = URLRequest(url: URL(string: \"https://archive.dramshok.art/api/v1/auth/profile\")!)\nrequest.httpMethod = \"POST\"\nrequest.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")\nrequest.httpBody = try JSONEncoder().encode(LoginBody(\n  profileId: 1,\n  pin: \"1234\",\n  deviceToken: \"ios-device-uuid\",\n  issueToken: true,\n  tokenName: \"iPhone\"\n))\nlet (data, _) = try await URLSession.shared.data(for: request)"}]}},"/auth/profile/auto":{"post":{"tags":["Auth"],"summary":"Авто-вход по токену устройства","description":"Работает, если устройство уже было привязано к профилю и PIN-версия не сброшена.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileAutoLoginRequest"},"examples":{"default":{"summary":"Авто-вход на знакомом устройстве","value":{"profileId":1,"deviceToken":"ios-device-uuid","issueToken":true,"tokenName":"iPhone"}}}}}},"responses":{"200":{"description":"Авто-вход выполнен.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/ProfileLoginResponse"}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X POST https://archive.dramshok.art/api/v1/auth/profile/auto \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"profileId\":1,\"deviceToken\":\"ios-device-uuid\",\"issueToken\":true}'"}]}},"/auth/logout":{"post":{"tags":["Auth"],"summary":"Выйти и отозвать bearer token","description":"Если передан bearer token, он будет отозван. Также сбрасывает профиль в cookie-сессии.","responses":{"200":{"description":"Выход выполнен.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"summary":"Выход выполнен","value":{"ok":true,"data":{"loggedOut":true}}}}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X POST https://archive.dramshok.art/api/v1/auth/logout \\\n  -H \"Authorization: Bearer dsa_exampleBearerTokenForMobileApp\""}]}},"/session":{"get":{"tags":["System"],"summary":"Получить состояние текущей сессии","description":"Поддерживает и cookie-сессию, и bearer token.","security":[],"responses":{"200":{"description":"Состояние сессии.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/SessionResponse"}}}}}},"x-codeSamples":[{"lang":"curl","label":"bearer","source":"curl https://archive.dramshok.art/api/v1/session \\\n  -H \"Authorization: Bearer dsa_exampleBearerTokenForMobileApp\""},{"lang":"curl","label":"cookie","source":"curl https://archive.dramshok.art/api/v1/session \\\n  -H \"Cookie: connect.sid=...\""}]}},"/me":{"get":{"tags":["Profiles"],"summary":"Получить текущий профиль","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Текущий профиль.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"summary":"Текущий профиль","value":{"ok":true,"data":{"profile":{"id":1,"name":"Платон","avatarPath":"/uploads/avatars/platon.jpg","avatarUrl":"https://archive.dramshok.art/uploads/avatars/platon.jpg","hasPin":true,"autoLogin":false,"pinVersion":1,"preferences":{"section":"all","search":"","status":"all"}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl https://archive.dramshok.art/api/v1/me \\\n  -H \"Authorization: Bearer dsa_exampleBearerTokenForMobileApp\""}]}},"/profiles":{"get":{"tags":["Profiles"],"summary":"Первый экран приложения: публичный список профилей","description":"Без пароля и авторизации. data.profiles содержит id, name, avatarUrl, hasPin, maintenanceBlocked. Покажите список, выберите профиль, запросите PIN и вызовите POST /auth/login. Предметы без входа не доступны. Пустой список: 200 с пустым массивом. data также содержит оформление и maintenance.","parameters":[{"name":"deviceToken","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Успешный ответ","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"object","required":["profiles","accentColor","onAccentColor","updatedAt","maintenance"],"properties":{"profiles":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/Profile"},{"type":"object","required":["maintenanceBlocked"],"properties":{"maintenanceBlocked":{"type":"boolean"}}}]}},"accentColor":{"type":"string","pattern":"^#[0-9A-Fa-f]{6}$"},"onAccentColor":{"type":"string","enum":["#000000","#FFFFFF"]},"updatedAt":{"type":"string","format":"date-time","nullable":true},"maintenance":{"type":"object","additionalProperties":true}}}}},"example":{"ok":true,"data":{"profiles":[{"id":1,"name":"Пример профиля","avatarPath":null,"avatarUrl":null,"hasPin":true,"autoLoginAvailable":false,"maintenanceBlocked":false}],"accentColor":"#AEF3FF","onAccentColor":"#000000","updatedAt":null,"maintenance":{"enabled":false,"allowedProfileIds":[]}}}}}}},"x-codeSamples":[{"lang":"curl","source":"curl 'https://archive.dramshok.art/api/v1/profiles'"},{"lang":"Swift","source":"let (data, response) = try await URLSession.shared.data(from: URL(string: \"https://archive.dramshok.art/api/v1/profiles\")!)\n// После проверки HTTP-статуса декодируйте data.profiles.\n// Полный SwiftUI-экран приведён в разделе быстрого старта."}],"security":[]}},"/items":{"get":{"tags":["Items"],"summary":"Получить список элементов","description":"Поддерживает фильтры и пагинацию. Фото возвращаются как `path` и абсолютный `url`.","security":[{"bearerAuth":[]}],"parameters":[{"name":"q","in":"query","schema":{"type":"string"},"description":"Поиск по названию, категории, типу, тегам, инвентарному ID и назначению."},{"name":"search","in":"query","schema":{"type":"string"},"description":"Алиас для `q`."},{"name":"section","in":"query","schema":{"type":"string","enum":["all","костюмы","реквизит","склад"]}},{"name":"status","in":"query","schema":{"type":"string","enum":["all","новый","хорошее","требует ремонта","списан"]}},{"name":"category","in":"query","schema":{"type":"string"}},{"name":"itemType","in":"query","schema":{"type":"string"}},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Offset"}],"responses":{"200":{"description":"Страница элементов.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/ItemsResponse"}}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl \"https://archive.dramshok.art/api/v1/items?q=шляпа&section=костюмы&limit=50&offset=0\" \\\n  -H \"Authorization: Bearer dsa_exampleBearerTokenForMobileApp\""},{"lang":"Swift","label":"URLSession","source":"var components = URLComponents(string: \"https://archive.dramshok.art/api/v1/items\")!\ncomponents.queryItems = [\n  URLQueryItem(name: \"q\", value: \"шляпа\"),\n  URLQueryItem(name: \"section\", value: \"костюмы\"),\n  URLQueryItem(name: \"limit\", value: \"50\")\n]\n\nvar request = URLRequest(url: components.url!)\nrequest.setValue(\"Bearer dsa_exampleBearerTokenForMobileApp\", forHTTPHeaderField: \"Authorization\")\nlet (data, _) = try await URLSession.shared.data(for: request)"}]},"post":{"tags":["Items"],"summary":"Создать элемент","description":"Принимает JSON без фото или `multipart/form-data` с файлами в поле `photos`. До 3 фото на элемент.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemInput"},"examples":{"default":{"$ref":"#/components/examples/CreateItemRequest"}}},"multipart/form-data":{"schema":{"allOf":[{"$ref":"#/components/schemas/ItemInput"},{"type":"object","properties":{"photos":{"type":"array","items":{"type":"string","format":"binary"}}}}]}}}},"responses":{"201":{"description":"Элемент создан.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/MutatedItemResponse"}}}}},"400":{"$ref":"#/components/responses/BadRequest"}},"x-codeSamples":[{"lang":"curl","label":"JSON","source":"curl -X POST https://archive.dramshok.art/api/v1/items \\\n  -H \"Authorization: Bearer dsa_exampleBearerTokenForMobileApp\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"Черная шляпа\",\"section\":\"костюмы\",\"category\":\"шляпы\",\"itemType\":\"головной убор\",\"conditionLabel\":\"хорошее\"}'"},{"lang":"curl","label":"multipart + photos","source":"curl -X POST https://archive.dramshok.art/api/v1/items \\\n  -H \"Authorization: Bearer dsa_exampleBearerTokenForMobileApp\" \\\n  -F \"name=Черная шляпа\" \\\n  -F \"section=костюмы\" \\\n  -F \"itemType=головной убор\" \\\n  -F \"conditionLabel=хорошее\" \\\n  -F \"photos=@/path/to/photo.jpg\""}]}},"/items/{id}":{"get":{"tags":["Items"],"summary":"Карточка по ID базы данных (номер из QR ?item=ID)","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/ItemId"}],"responses":{"200":{"description":"Карточка элемента.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/ItemResponse"}}}}},"400":{"description":"Укажите положительный целочисленный ID элемента.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":400,"code":"INVALID_ITEM_ID","message":"Укажите положительный целочисленный ID элемента."}}}}},"404":{"$ref":"#/components/responses/NotFound"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl https://archive.dramshok.art/api/v1/items/123 \\\n  -H \"Authorization: Bearer dsa_exampleBearerTokenForMobileApp\""}],"description":"undefined\n\nДля ссылки https://archive.dramshok.art/?item=123 вызывайте GET /api/v1/items/123. Это именно items.id, не инвентарный номер. Ответ: data.item (полная карточка) и data.similar. Нужен Bearer-токен. Пример Swift QR-сканера находится в разделе быстрого старта."},"put":{"tags":["Items"],"summary":"Полностью обновить элемент","description":"Для JSON-обновления передайте все обязательные поля. Для фото используйте `multipart/form-data` и `keepPhotos`.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/ItemId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemInput"},"examples":{"default":{"$ref":"#/components/examples/CreateItemRequest"}}},"multipart/form-data":{"schema":{"$ref":"#/components/schemas/ItemInput"}}}},"responses":{"200":{"description":"Элемент обновлен.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/MutatedItemResponse"}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X PUT https://archive.dramshok.art/api/v1/items/123 \\\n  -H \"Authorization: Bearer dsa_exampleBearerTokenForMobileApp\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"Черная шляпа\",\"section\":\"костюмы\",\"category\":\"шляпы\",\"itemType\":\"головной убор\",\"conditionLabel\":\"хорошее\"}'"}]},"patch":{"tags":["Items"],"summary":"Частично обновить элемент","description":"Удобно для приложений: можно отправлять только изменившиеся поля.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/ItemId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemInput"},"examples":{"default":{"$ref":"#/components/examples/PatchItemRequest"}}},"multipart/form-data":{"schema":{"$ref":"#/components/schemas/ItemInput"}}}},"responses":{"200":{"description":"Элемент обновлен.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/MutatedItemResponse"}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X PATCH https://archive.dramshok.art/api/v1/items/123 \\\n  -H \"Authorization: Bearer dsa_exampleBearerTokenForMobileApp\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"conditionLabel\":\"требует ремонта\",\"notes\":\"Проверить крепление\"}'"},{"lang":"Swift","label":"URLSession","source":"var request = URLRequest(url: URL(string: \"https://archive.dramshok.art/api/v1/items/123\")!)\nrequest.httpMethod = \"PATCH\"\nrequest.setValue(\"Bearer dsa_exampleBearerTokenForMobileApp\", forHTTPHeaderField: \"Authorization\")\nrequest.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")\nrequest.httpBody = try JSONEncoder().encode([\n  \"conditionLabel\": \"требует ремонта\",\n  \"notes\": \"Проверить крепление\"\n])\nlet (data, _) = try await URLSession.shared.data(for: request)"}]},"delete":{"tags":["Items"],"summary":"Удалить элемент","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/ItemId"}],"responses":{"200":{"description":"Элемент удален.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/DeleteResponse"}}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/items/{id}/history":{"get":{"tags":["Items"],"summary":"Получить историю изменений элемента","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/ItemId"}],"responses":{"200":{"description":"История элемента.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/HistoryResponse"}}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/items/{id}/qr":{"get":{"tags":["Items"],"summary":"Получить QR-код элемента","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/ItemId"}],"responses":{"200":{"description":"URL и dataURL QR-кода.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/QrResponse"}}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/inventory/next-id":{"get":{"tags":["Inventory"],"summary":"Подобрать следующий инвентарный ID","security":[{"bearerAuth":[]}],"parameters":[{"name":"section","in":"query","required":true,"schema":{"type":"string","enum":["костюмы","реквизит","склад"]}},{"name":"itemType","in":"query","schema":{"type":"string"},"description":"Нужен для костюмов/реквизита."},{"name":"category","in":"query","schema":{"type":"string"},"description":"Нужна для склада."}],"responses":{"200":{"description":"Следующий инвентарный ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessEnvelope"},"examples":{"default":{"$ref":"#/components/examples/NextIdResponse"}}}}},"400":{"$ref":"#/components/responses/BadRequest"}},"x-codeSamples":[{"lang":"curl","label":"Костюмы/реквизит","source":"curl \"https://archive.dramshok.art/api/v1/inventory/next-id?section=костюмы&itemType=одежда\" \\\n  -H \"Authorization: Bearer dsa_exampleBearerTokenForMobileApp\""},{"lang":"curl","label":"Склад","source":"curl \"https://archive.dramshok.art/api/v1/inventory/next-id?section=склад&category=ткани\" \\\n  -H \"Authorization: Bearer dsa_exampleBearerTokenForMobileApp\""}]}},"/profile/change-pin":{"post":{"tags":["Auth"],"summary":"Сменить PIN, в том числе временный","security":[{"bearerAuth":[]}],"description":"При profile.mustChangePin=true разрешены /me и этот маршрут, данные архива возвращают 403 PIN_CHANGE_REQUIRED. Новый PIN должен отличаться от текущего. После смены старые токены отозваны: сохраните новый data.accessToken.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["currentPin","newPin"],"properties":{"currentPin":{"type":"string"},"newPin":{"type":"string","pattern":"^[0-9]{4,8}$"}}},"example":{"currentPin":"0123","newPin":"5678"}}}},"responses":{"200":{"description":"Успешный ответ","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"object","required":["accessToken","expiresAt","tokenType","mustChangePin"],"properties":{"accessToken":{"type":"string"},"expiresAt":{"type":"string","format":"date-time"},"tokenType":{"type":"string"},"mustChangePin":{"type":"boolean"}}}}}}}},"400":{"description":"Неверный текущий PIN, формат нового или совпадение со старым."},"401":{"description":"Неверный PIN или недействительный токен.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":401,"code":"INVALID_PIN","message":"Неверный PIN или недействительный токен."}}}}},"409":{"description":"PIN изменён другим запросом. Войдите снова."},"429":{"description":"Слишком много попыток входа.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"error":{"status":429,"code":"RATE_LIMITED","message":"Слишком много попыток входа."}}}},"headers":{"Retry-After":{"description":"Через сколько секунд повторить запрос","schema":{"type":"integer"}}}}}}}}}