{
  "info": {
    "name": "Makroskop API",
    "description": "REST + realtime API of the Makroskop backend (Elixir/Phoenix). Messages are stored encrypted on the server and synced by `seq` cursor (see documents/07-api.md → Senkron modeli).\n\n**Quick start (local):** pick the *Makroskop — local* environment → run *Auth / Dev login* (saves tokens) → *Account / Set username* → *Rooms / Create room* (saves roomSlug) → everything else.\n\n**Auth:** Bearer access token (1 h). When a call answers `401 token_expired`, run *Auth / Refresh* — the refresh token rotates on every use.\n\n**Errors** always look like `{\"error\": {\"code\": \"...\", \"message\": \"...\", \"fields\": {...}}}`; `code` is stable, `message` is for humans.\n\nWebSocket (Phoenix Channels v2). Postman's collection format can't hold WebSocket requests, so create one in the workspace: **New → WebSocket**, URL:\n\n`{{wsUrl}}/socket/websocket?token={{accessToken}}&vsn=2.0.0`\n\nEvery frame is a JSON array: `[join_ref, ref, topic, event, payload]`. Send `phx_join` first, then any client event. Keep the connection alive with a heartbeat every 30 s: `[null, \"hb\", \"phoenix\", \"heartbeat\", {}]`.\n\nReady-to-paste messages (replace the slug):\n\n```\n[\"1\",\"1\",\"room:<slug>\",\"phx_join\",{}]\n[\"1\",\"2\",\"room:<slug>\",\"message:new\",{\"body\":\"Maç ne oldu? #fb-maçı\",\"client_ref\":\"c-1\"}]\n[\"1\",\"3\",\"room:<slug>\",\"message:ack\",{\"ids\":[\"<message_id>\"]}]\n[\"1\",\"4\",\"room:<slug>\",\"typing\",{}]\n[\"1\",\"5\",\"room:<slug>\",\"presence:set\",{\"state\":\"background\"}]\n[\"1\",\"6\",\"room:<slug>\",\"presence:set\",{\"state\":\"online\"}]\n[null,\"hb\",\"phoenix\",\"heartbeat\",{}]\n```\n\nFull event list with real captured outputs: documents/07-api.md in the repo.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{accessToken}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "baseUrl",
      "value": "http://localhost:4000"
    },
    {
      "key": "wsUrl",
      "value": "ws://localhost:4000"
    },
    {
      "key": "accessToken",
      "value": ""
    },
    {
      "key": "refreshToken",
      "value": ""
    },
    {
      "key": "roomSlug",
      "value": ""
    },
    {
      "key": "username",
      "value": ""
    },
    {
      "key": "roomAvatarId",
      "value": ""
    },
    {
      "key": "roomAvatarKey",
      "value": ""
    },
    {
      "key": "roomAvatarThumbKey",
      "value": ""
    },
    {
      "key": "messageId",
      "value": "",
      "type": "string"
    },
    {
      "key": "adminToken",
      "value": "",
      "type": "string"
    }
  ],
  "item": [
    {
      "name": "Health",
      "description": "Liveness + dependency check.",
      "item": [
        {
          "name": "Health",
          "event": [],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/health",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "health"
              ]
            },
            "description": "200 when Postgres, Redis and Mnesia answer, 503 `degraded` otherwise. `minio` is reported but not fatal.",
            "auth": {
              "type": "noauth"
            }
          },
          "response": [
            {
              "name": "200 ok",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/health",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "health"
                  ]
                },
                "description": "200 when Postgres, Redis and Mnesia answer, 503 `degraded` otherwise. `minio` is reported but not fatal.",
                "auth": {
                  "type": "noauth"
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"status\": \"ok\",\n  \"mnesia\": true,\n  \"redis\": true,\n  \"postgres\": true,\n  \"minio\": true\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Auth",
      "description": "Every successful sign-in mints a NEW anonymous account — there's no link back to the GlobalTrustID identity, so the refresh token is the only way back into an account.",
      "item": [
        {
          "name": "Dev login (dev/test only)",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const b = pm.response.json();",
                  "if (b.access_token) {",
                  "  pm.collectionVariables.set(\"accessToken\", b.access_token);",
                  "  pm.collectionVariables.set(\"refreshToken\", b.refresh_token);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/auth/dev",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "auth",
                "dev"
              ]
            },
            "description": "Skips GlobalTrustID. 404 on the live server. Saves accessToken/refreshToken into collection variables.",
            "auth": {
              "type": "noauth"
            }
          },
          "response": [
            {
              "name": "201 new account",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/auth/dev",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "auth",
                    "dev"
                  ]
                },
                "description": "Skips GlobalTrustID. 404 on the live server. Saves accessToken/refreshToken into collection variables.",
                "auth": {
                  "type": "noauth"
                }
              },
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"refresh_token\": \"<refresh_token>\",\n  \"expires_in\": 3600,\n  \"account\": {\n    \"username\": null,\n    \"account_id\": \"f6cad462-5db2-444f-9347-f1ac20b68431\",\n    \"created_at\": \"2026-09-22T09:02:35.247850Z\"\n  },\n  \"access_token\": \"<access_token>\",\n  \"refresh_expires_in\": 7776000,\n  \"token_type\": \"Bearer\"\n}"
            }
          ]
        },
        {
          "name": "GlobalTrustID login",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const b = pm.response.json();",
                  "if (b.access_token) {",
                  "  pm.collectionVariables.set(\"accessToken\", b.access_token);",
                  "  pm.collectionVariables.set(\"refreshToken\", b.refresh_token);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/auth/globaltrustid",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "auth",
                "globaltrustid"
              ]
            },
            "description": "The app runs OIDC authorization-code + PKCE against GlobalTrustID, then posts the code here. The backend exchanges it (with its client secret), verifies the id_token against GTID's JWKS (iss/aud/exp) and returns the same body as Dev login.\n\nErrors: 400 invalid_request, 401 gtid_rejected | invalid_id_token, 502 gtid_unavailable, 503 gtid_not_configured.",
            "auth": {
              "type": "noauth"
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"code\": \"<authorization_code>\",\n  \"code_verifier\": \"<pkce_verifier>\",\n  \"redirect_uri\": \"makroskop://auth/callback\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 new account",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/auth/globaltrustid",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "auth",
                    "globaltrustid"
                  ]
                },
                "description": "The app runs OIDC authorization-code + PKCE against GlobalTrustID, then posts the code here. The backend exchanges it (with its client secret), verifies the id_token against GTID's JWKS (iss/aud/exp) and returns the same body as Dev login.\n\nErrors: 400 invalid_request, 401 gtid_rejected | invalid_id_token, 502 gtid_unavailable, 503 gtid_not_configured.",
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"code\": \"<authorization_code>\",\n  \"code_verifier\": \"<pkce_verifier>\",\n  \"redirect_uri\": \"makroskop://auth/callback\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"refresh_token\": \"<refresh_token>\",\n  \"expires_in\": 3600,\n  \"account\": {\n    \"username\": null,\n    \"account_id\": \"f6cad462-5db2-444f-9347-f1ac20b68431\",\n    \"created_at\": \"2026-09-22T09:02:35.247850Z\"\n  },\n  \"access_token\": \"<access_token>\",\n  \"refresh_expires_in\": 7776000,\n  \"token_type\": \"Bearer\"\n}"
            },
            {
              "name": "503 not configured",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/auth/globaltrustid",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "auth",
                    "globaltrustid"
                  ]
                },
                "description": "The app runs OIDC authorization-code + PKCE against GlobalTrustID, then posts the code here. The backend exchanges it (with its client secret), verifies the id_token against GTID's JWKS (iss/aud/exp) and returns the same body as Dev login.\n\nErrors: 400 invalid_request, 401 gtid_rejected | invalid_id_token, 502 gtid_unavailable, 503 gtid_not_configured.",
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"code\": \"<authorization_code>\",\n  \"code_verifier\": \"<pkce_verifier>\",\n  \"redirect_uri\": \"makroskop://auth/callback\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Service Unavailable",
              "code": 503,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"gtid_not_configured\",\n    \"message\": \"GlobalTrustID login isn't configured on this server.\"\n  }\n}"
            }
          ]
        },
        {
          "name": "Refresh",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const b = pm.response.json();",
                  "if (b.access_token) {",
                  "  pm.collectionVariables.set(\"accessToken\", b.access_token);",
                  "  pm.collectionVariables.set(\"refreshToken\", b.refresh_token);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/auth/refresh",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "auth",
                "refresh"
              ]
            },
            "description": "Rotates: the refresh token you send dies, a new pair comes back.",
            "auth": {
              "type": "noauth"
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"refresh_token\": \"{{refreshToken}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 new pair",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/auth/refresh",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "auth",
                    "refresh"
                  ]
                },
                "description": "Rotates: the refresh token you send dies, a new pair comes back.",
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"refresh_token\": \"<refresh_token>\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"refresh_token\": \"<refresh_token>\",\n  \"expires_in\": 3600,\n  \"access_token\": \"<access_token>\",\n  \"refresh_expires_in\": 7776000,\n  \"token_type\": \"Bearer\"\n}"
            },
            {
              "name": "401 reused token",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/auth/refresh",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "auth",
                    "refresh"
                  ]
                },
                "description": "Rotates: the refresh token you send dies, a new pair comes back.",
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"refresh_token\": \"<old_refresh_token>\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Unauthorized",
              "code": 401,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"invalid_refresh_token\",\n    \"message\": \"Refresh token is unknown, used or expired.\"\n  }\n}"
            }
          ]
        },
        {
          "name": "Logout",
          "event": [],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/auth/logout",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "auth",
                "logout"
              ]
            },
            "description": "Ends this device's session. Always 204 (idempotent).",
            "auth": {
              "type": "noauth"
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"refresh_token\": \"{{refreshToken}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "204",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/auth/logout",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "auth",
                    "logout"
                  ]
                },
                "description": "Ends this device's session. Always 204 (idempotent).",
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"refresh_token\": \"{{refreshToken}}\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "No Content",
              "code": 204,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": ""
            }
          ]
        }
      ]
    },
    {
      "name": "Account",
      "description": "The anonymous account: account_id + username, nothing else.",
      "item": [
        {
          "name": "Me",
          "event": [],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/me",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me"
              ]
            }
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/me",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "me"
                  ]
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"account\": {\n    \"username\": null,\n    \"account_id\": \"f6cad462-5db2-444f-9347-f1ac20b68431\",\n    \"created_at\": \"2026-09-22T09:02:35.247850Z\"\n  }\n}"
            },
            {
              "name": "401 no token",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/me",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "me"
                  ]
                }
              },
              "status": "Unauthorized",
              "code": 401,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"unauthorized\",\n    \"message\": \"Missing or invalid access token.\"\n  }\n}"
            }
          ]
        },
        {
          "name": "Username available?",
          "event": [],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/usernames/available?username={{username}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "usernames",
                "available"
              ],
              "query": [
                {
                  "key": "username",
                  "value": "{{username}}"
                }
              ]
            },
            "description": "Live feedback while the user types their nickname. Case-insensitive.",
            "auth": {
              "type": "noauth"
            }
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/usernames/available?username={{username}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "usernames",
                    "available"
                  ],
                  "query": [
                    {
                      "key": "username",
                      "value": "{{username}}"
                    }
                  ]
                },
                "description": "Live feedback while the user types their nickname. Case-insensitive.",
                "auth": {
                  "type": "noauth"
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"available\": true,\n  \"username\": \"ayse_g4fqi\"\n}"
            }
          ]
        },
        {
          "name": "Set username",
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (!pm.collectionVariables.get(\"username\")) {",
                  "  pm.collectionVariables.set(\"username\", \"user_\" + Math.random().toString(36).slice(2, 8));",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/me/username",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me",
                "username"
              ]
            },
            "description": "Onboarding's last step (rumuz). 3–24 chars: letters (Turkish included), digits, '_' or '.'. Rooms/uploads/socket answer 403 `username_required` until this is set.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"username\": \"{{username}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/me/username",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "me",
                    "username"
                  ]
                },
                "description": "Onboarding's last step (rumuz). 3–24 chars: letters (Turkish included), digits, '_' or '.'. Rooms/uploads/socket answer 403 `username_required` until this is set.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"username\": \"ayse_…\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"account\": {\n    \"username\": \"ayse_g4fqi\",\n    \"account_id\": \"f6cad462-5db2-444f-9347-f1ac20b68431\",\n    \"created_at\": \"2026-09-22T09:02:35.247850Z\"\n  }\n}"
            },
            {
              "name": "422 invalid",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/me/username",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "me",
                    "username"
                  ]
                },
                "description": "Onboarding's last step (rumuz). 3–24 chars: letters (Turkish included), digits, '_' or '.'. Rooms/uploads/socket answer 403 `username_required` until this is set.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"username\": \"a b\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"validation_failed\",\n    \"message\": \"Some fields are invalid.\",\n    \"fields\": {\n      \"username\": [\n        \"3-24 characters: letters, digits, '_' or '.'\"\n      ]\n    }\n  }\n}"
            }
          ]
        },
        {
          "name": "Update profile & privacy",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/me/profile",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me",
                "profile"
              ]
            },
            "description": "bio max 200; dm_policy open|closed|paid (paid ise dm_star_cost>0); photo_visibility everyone|followers|nobody.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"bio\": \"Merhaba\",\n  \"dm_policy\": \"open\",\n  \"dm_star_cost\": 0,\n  \"photo_visibility\": \"everyone\",\n  \"hidden_profile\": false\n}"
            }
          }
        },
        {
          "name": "Stars",
          "event": [],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/me/stars",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me",
                "stars"
              ]
            },
            "description": "Balance (sum of the ledger) and the latest 50 entries. Earning/spending lands with the star economy step."
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/me/stars",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "me",
                    "stars"
                  ]
                },
                "description": "Balance (sum of the ledger) and the latest 50 entries. Earning/spending lands with the star economy step."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"balance\": 0,\n  \"entries\": []\n}"
            }
          ]
        },
        {
          "name": "Register device",
          "event": [],
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/me/device",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me",
                "device"
              ]
            },
            "description": "204. Cihazın kendini tanıtması: `{platform, push_token?, device_model?, device_manufacturer?, is_emulator?}`. Her açılışta gönderilir; idempotent.\n\nYalnız `platform` zorunlu — `android` | `ios` | `web_app` | `web_integration` (son ikisinin istemcisi henüz yok), başka değer `400 invalid_request`.\n\n`push_token` **isteğe bağlı**: token alamayan cihaz (iOS simülatörü, bildirime izin vermeyen kullanıcı) da kendini söyleyebilsin diye. Göndermemek \"token'a dokunma\" demektir, \"token'ı sil\" değil — silmek `DELETE /api/me/device`'ın işi. Aynı token başka oturumdaysa buraya taşınır.\n\n`device_model` / `device_manufacturer` trim'lenir, max 64 (Android `Build.MODEL` / `Build.MANUFACTURER`, iOS `iPhone16,2` / `Apple`). `is_emulator` gerçek bir boolean olmalı; hiç göndermemek serbest ve `false` ile aynı şey değil (üç durum: evet / hayır / söylemedi).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"platform\": \"android\",\n  \"push_token\": \"<fcm_registration_token>\",\n  \"device_model\": \"SM-S911B\",\n  \"device_manufacturer\": \"samsung\",\n  \"is_emulator\": false\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Unregister device (push)",
          "event": [],
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/me/device",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me",
                "device"
              ]
            },
            "description": "204. Stops pushes to this device. Logout does the same."
          },
          "response": []
        },
        {
          "name": "My photos",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/me/avatars",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me",
                "avatars"
              ]
            },
            "description": "Fotoğraflarım, yüz olan başta."
          }
        },
        {
          "name": "Presign my photo",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/me/avatars/presign",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me",
                "avatars",
                "presign"
              ]
            },
            "description": "full + thumb presigned PUT; oda fotoğrafıyla aynı akış.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"content_type\": \"image/jpeg\"\n}"
            }
          }
        },
        {
          "name": "Add my photo",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/me/avatars",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me",
                "avatars"
              ]
            },
            "description": "Yüklenen fotoğrafı kaydeder (`primary` varsayılan true). Yüz değiştiyse bağlı olan herkese socket'ten `account:avatar` `{account_id, avatar}` duyurulur; son fotoğraf da silinince `avatar: null` gider.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"key\": \"\",\n  \"thumb_key\": \"\",\n  \"size_px\": 512\n}"
            }
          }
        },
        {
          "name": "My groups",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/me/rooms",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me",
                "rooms"
              ]
            },
            "description": "{founded, administered} — profil sekmeleri."
          }
        },
        {
          "name": "My followers",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/me/followers",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me",
                "followers"
              ]
            },
            "description": ""
          }
        },
        {
          "name": "Who I follow",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/me/following",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "me",
                "following"
              ]
            },
            "description": ""
          }
        }
      ]
    },
    {
      "name": "Rooms",
      "description": "Groups. Requires a username.",
      "item": [
        {
          "name": "My rooms",
          "event": [],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/rooms",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms"
              ]
            },
            "description": "Üyesi olduğum odalar (arşivdekiler dahil). Sıra: **en son konuşulan oda başta** — hiç konuşulmamış oda `joined_at`'e düşer, eşitliği oda kimliği bozar. Her satırda `my_role`, `joined_at`, `archived`, `muted`, `avatar`, ayrıca listenin çizdiği `last_message` (son mesajın ilk 120 karakteri) + `unread` (okuma imlecinden sonrası). Canlı hâli socket'teki `room:activity` olayıdır."
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms"
                  ]
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"rooms\": [\n    {\n      \"id\": \"9b7e579c-01a6-4bb4-9ef7-876b9217f58e\",\n      \"name\": \"Fenerbahçe Taraftar g4fqi\",\n      \"created_at\": \"2026-09-22T09:02:35.365549Z\",\n      \"slug\": \"fenerbahce-taraftar-g4fqi\",\n      \"star_gated\": false,\n      \"founder_id\": \"f6cad462-5db2-444f-9347-f1ac20b68431\",\n      \"visibility\": \"open\",\n      \"welcome_message\": null,\n      \"message_ttl_hours\": null,\n      \"level\": 0,\n      \"stars\": 0,\n      \"gifts\": 0,\n      \"vanity_code\": null,\n      \"wallpaper\": \"okyanus\", \"short_link\": \"mskp.io/fenerbahce-taraftar-g4fqi\",\n      \"joined_at\": \"2026-09-22T09:02:35.364222Z\",\n      \"my_role\": \"founder\",\n      \"archived\": false,\n      \"avatar\": {\n        \"id\": \"3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8\",\n        \"is_primary\": true,\n        \"content_type\": \"image/jpeg\",\n        \"size_px\": 1024,\n        \"byte_size\": 118432,\n        \"created_at\": \"2026-09-23T09:14:02.118374Z\",\n        \"url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=a41f0d6b2c8e5379d04b1fa6c39e82d7501b6ac4f8e39d2b7061ac5f4e93d281\",\n        \"thumb_url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8-thumb.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=c73e90ab15d648f2091cb73ea56d4802f19ce64b83d71a05f2ec9b14d70a6538\"\n      }\n    }\n  ]\n}"
            },
            {
              "name": "403 username required",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms"
                  ]
                }
              },
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"username_required\",\n    \"message\": \"Pick a username first (PUT /api/me/username).\"\n  }\n}"
            }
          ]
        },
        {
          "name": "Create room",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const b = pm.response.json();",
                  "if (b.room) pm.collectionVariables.set(\"roomSlug\", b.room.slug);"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms"
              ]
            },
            "description": "`slug` optional (derived from the name, suffixed on collision). `visibility` `open` (link is enough) or `closed` (an admin lets people in), `message_ttl_hours` auto-delete in 1–8760 h (omit = messages stay). Creator becomes founder + first member. The photo follows as a separate upload. Saves roomSlug.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Fenerbahçe Taraftar\",\n  \"slug\": \"\",\n  \"star_gated\": false,\n  \"visibility\": \"open\",\n  \"message_ttl_hours\": 24,\n  \"welcome_message\": \"Hoş geldin! Küfür yok, maç var.\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms"
                  ]
                },
                "description": "`slug` optional (derived from the name, suffixed on collision). `visibility` `open` (link is enough) or `closed` (an admin lets people in), `message_ttl_hours` auto-delete in 1–8760 h (omit = messages stay). Creator becomes founder + first member. The photo follows as a separate upload. Saves roomSlug.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Fenerbahçe Taraftar\",\n  \"message_ttl_hours\": 24,\n  \"welcome_message\": \"Hoş geldin!\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"room\": {\n    \"id\": \"9b7e579c-01a6-4bb4-9ef7-876b9217f58e\",\n    \"name\": \"Fenerbahçe Taraftar g4fqi\",\n    \"created_at\": \"2026-09-22T09:02:35.365549Z\",\n    \"slug\": \"fenerbahce-taraftar-g4fqi\",\n    \"star_gated\": false,\n    \"founder_id\": \"f6cad462-5db2-444f-9347-f1ac20b68431\",\n    \"visibility\": \"open\",\n    \"welcome_message\": \"Hoş geldin!\",\n    \"message_ttl_hours\": 24,\n    \"level\": 0,\n    \"stars\": 0,\n    \"gifts\": 0,\n    \"vanity_code\": null,\n    \"wallpaper\": \"okyanus\", \"short_link\": \"mskp.io/fenerbahce-taraftar-g4fqi\",\n    \"member_count\": 1,\n    \"my_role\": \"founder\",\n    \"archived\": false,\n    \"avatar\": null\n  }\n}"
            },
            {
              "name": "422 invalid slug",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms"
                  ]
                },
                "description": "`slug` optional (derived from the name, suffixed on collision). `visibility` `open` (link is enough) or `closed` (an admin lets people in), `message_ttl_hours` auto-delete in 1–8760 h (omit = messages stay). Creator becomes founder + first member. The photo follows as a separate upload. Saves roomSlug.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"X\",\n  \"slug\": \"Büyük Harf\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"validation_failed\",\n    \"message\": \"Some fields are invalid.\",\n    \"fields\": {\n      \"slug\": [\n        \"lowercase letters, digits and '-' only\"\n      ]\n    }\n  }\n}"
            }
          ]
        },
        {
          "name": "Room by slug",
          "event": [],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}"
              ]
            },
            "description": "Visible to anyone signed in (invite links); `is_member` says whether you're in."
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}"
                  ]
                },
                "description": "Visible to anyone signed in (invite links); `is_member` says whether you're in."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"room\": {\n    \"id\": \"9b7e579c-01a6-4bb4-9ef7-876b9217f58e\",\n    \"name\": \"Fenerbahçe Taraftar g4fqi\",\n    \"created_at\": \"2026-09-22T09:02:35.365549Z\",\n    \"slug\": \"fenerbahce-taraftar-g4fqi\",\n    \"star_gated\": false,\n    \"founder_id\": \"f6cad462-5db2-444f-9347-f1ac20b68431\",\n    \"visibility\": \"open\",\n    \"welcome_message\": null,\n    \"message_ttl_hours\": null,\n    \"level\": 0,\n    \"stars\": 0,\n    \"gifts\": 0,\n    \"vanity_code\": null,\n    \"wallpaper\": \"okyanus\", \"short_link\": \"mskp.io/fenerbahce-taraftar-g4fqi\",\n    \"member_count\": 1,\n    \"is_member\": false,\n    \"avatar\": {\n      \"id\": \"3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8\",\n      \"is_primary\": true,\n      \"content_type\": \"image/jpeg\",\n      \"size_px\": 1024,\n      \"byte_size\": 118432,\n      \"created_at\": \"2026-09-23T09:14:02.118374Z\",\n      \"url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=a41f0d6b2c8e5379d04b1fa6c39e82d7501b6ac4f8e39d2b7061ac5f4e93d281\",\n      \"thumb_url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8-thumb.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=c73e90ab15d648f2091cb73ea56d4802f19ce64b83d71a05f2ec9b14d70a6538\"\n    }\n  }\n}"
            }
          ]
        },
        {
          "name": "Update room settings",
          "event": [],
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}"
              ]
            },
            "description": "Admins only (`403 not_admin`). Name, open/closed, welcome message and auto-delete — never the slug. What you leave out stays as it is; `\"message_ttl_hours\": null` turns auto-delete off again.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Fenerbahçe Taraftar\",\n  \"visibility\": \"closed\",\n  \"welcome_message\": \"Hoş geldin! Küfür yok, maç var.\",\n  \"message_ttl_hours\": 24,\n  \"wallpaper\": \"lavanta\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}"
                  ]
                },
                "description": "Admins only (`403 not_admin`). Name, open/closed, welcome message and auto-delete — never the slug. What you leave out stays as it is; `\"message_ttl_hours\": null` turns auto-delete off again.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Fenerbahçe Taraftar\",\n  \"visibility\": \"closed\",\n  \"welcome_message\": \"Hoş geldin! Küfür yok, maç var.\",\n  \"message_ttl_hours\": 24,\n  \"wallpaper\": \"lavanta\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"room\": {\n    \"id\": \"9b7e579c-01a6-4bb4-9ef7-876b9217f58e\",\n    \"name\": \"Fenerbahçe Taraftar\",\n    \"created_at\": \"2026-09-22T09:02:35.365549Z\",\n    \"slug\": \"fenerbahce-taraftar-g4fqi\",\n    \"star_gated\": false,\n    \"founder_id\": \"f6cad462-5db2-444f-9347-f1ac20b68431\",\n    \"visibility\": \"closed\",\n    \"welcome_message\": \"Hoş geldin! Küfür yok, maç var.\",\n    \"message_ttl_hours\": 24,\n    \"level\": 0,\n    \"stars\": 0,\n    \"gifts\": 0,\n    \"vanity_code\": null,\n    \"wallpaper\": \"okyanus\", \"short_link\": \"mskp.io/fenerbahce-taraftar-g4fqi\",\n    \"member_count\": 2,\n    \"is_member\": true,\n    \"avatar\": {\n      \"id\": \"3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8\",\n      \"is_primary\": true,\n      \"content_type\": \"image/jpeg\",\n      \"size_px\": 1024,\n      \"byte_size\": 118432,\n      \"created_at\": \"2026-09-23T09:14:02.118374Z\",\n      \"url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=a41f0d6b2c8e5379d04b1fa6c39e82d7501b6ac4f8e39d2b7061ac5f4e93d281\",\n      \"thumb_url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8-thumb.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=c73e90ab15d648f2091cb73ea56d4802f19ce64b83d71a05f2ec9b14d70a6538\"\n    }\n  }\n}"
            },
            {
              "name": "403 not an admin",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}"
                  ]
                },
                "description": "Admins only (`403 not_admin`). Name, open/closed, welcome message and auto-delete — never the slug. What you leave out stays as it is; `\"message_ttl_hours\": null` turns auto-delete off again.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Ele Geçirdim\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"not_admin\",\n    \"message\": \"Only the room's admins can do that.\"\n  }\n}"
            },
            {
              "name": "422 unknown visibility",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}"
                  ]
                },
                "description": "Admins only (`403 not_admin`). Name, open/closed, welcome message and auto-delete — never the slug. What you leave out stays as it is; `\"message_ttl_hours\": null` turns auto-delete off again.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"visibility\": \"secret\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"validation_failed\",\n    \"message\": \"Some fields are invalid.\",\n    \"fields\": {\n      \"visibility\": [\n        \"is invalid\"\n      ]\n    }\n  }\n}"
            }
          ]
        },
        {
          "name": "Join",
          "event": [],
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/join",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "join"
              ]
            },
            "description": "`joined` or `already_member`. Star-gated rooms answer 402 `star_required` for now. Broadcasts `member:joined` on the room channel."
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/join",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "join"
                  ]
                },
                "description": "`joined` or `already_member`. Star-gated rooms answer 402 `star_required` for now. Broadcasts `member:joined` on the room channel."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"status\": \"joined\",\n  \"room\": {\n    \"id\": \"9b7e579c-01a6-4bb4-9ef7-876b9217f58e\",\n    \"name\": \"Fenerbahçe Taraftar g4fqi\",\n    \"created_at\": \"2026-09-22T09:02:35.365549Z\",\n    \"slug\": \"fenerbahce-taraftar-g4fqi\",\n    \"star_gated\": false,\n    \"founder_id\": \"f6cad462-5db2-444f-9347-f1ac20b68431\",\n    \"visibility\": \"open\",\n    \"welcome_message\": null,\n    \"message_ttl_hours\": null,\n    \"level\": 0,\n    \"stars\": 0,\n    \"gifts\": 0,\n    \"vanity_code\": null,\n    \"wallpaper\": \"okyanus\", \"short_link\": \"mskp.io/fenerbahce-taraftar-g4fqi\",\n    \"member_count\": 2,\n    \"is_member\": true,\n    \"avatar\": {\n      \"id\": \"3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8\",\n      \"is_primary\": true,\n      \"content_type\": \"image/jpeg\",\n      \"size_px\": 1024,\n      \"byte_size\": 118432,\n      \"created_at\": \"2026-09-23T09:14:02.118374Z\",\n      \"url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=a41f0d6b2c8e5379d04b1fa6c39e82d7501b6ac4f8e39d2b7061ac5f4e93d281\",\n      \"thumb_url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8-thumb.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=c73e90ab15d648f2091cb73ea56d4802f19ce64b83d71a05f2ec9b14d70a6538\"\n    }\n  }\n}"
            }
          ]
        },
        {
          "name": "Leave",
          "event": [],
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/leave",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "leave"
              ]
            },
            "description": "Releases your hold on undelivered messages and broadcasts `member:left`; your own open channels get `room:removed` and close. The founder can't leave (409)."
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/leave",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "leave"
                  ]
                },
                "description": "Releases your hold on undelivered messages and broadcasts `member:left`; your own open channels get `room:removed` and close. The founder can't leave (409)."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"status\": \"left\"\n}"
            },
            {
              "name": "409 founder",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/leave",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "leave"
                  ]
                },
                "description": "Releases your hold on undelivered messages and broadcasts `member:left`; your own open channels get `room:removed` and close. The founder can't leave (409)."
              },
              "status": "Conflict",
              "code": 409,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"founder_cannot_leave\",\n    \"message\": \"The founder can't leave their own room.\"\n  }\n}"
            }
          ]
        },
        {
          "name": "Members",
          "event": [],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/members",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "members"
              ]
            }
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/members",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "members"
                  ]
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"members\": [\n    {\n      \"username\": \"ayse_g4fqi\",\n      \"role\": \"founder\",\n      \"account_id\": \"f6cad462-5db2-444f-9347-f1ac20b68431\",\n      \"joined_at\": \"2026-09-22T09:02:35.364222Z\"\n    },\n    {\n      \"username\": \"mehmet_g4fqi\",\n      \"role\": \"member\",\n      \"account_id\": \"fdeaca7c-5b33-48c2-9c76-be745ab4d5d6\",\n      \"joined_at\": \"2026-09-22T09:02:35.635861Z\"\n    }\n  ]\n}"
            },
            {
              "name": "403 not a member",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/members",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "members"
                  ]
                }
              },
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"not_member\",\n    \"message\": \"You are not a member of this room.\"\n  }\n}"
            }
          ]
        },
        {
          "name": "Topics (hashtag strip)",
          "event": [],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/topics",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "topics"
              ]
            },
            "description": "Up to 100 topics, most recently active first. Messages without a hashtag count under `unknown` (`\"unknown\": true`)."
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/topics",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "topics"
                  ]
                },
                "description": "Up to 100 topics, most recently active first. Messages without a hashtag count under `unknown` (`\"unknown\": true`)."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"topics\": [\n    {\n      \"unknown\": true,\n      \"hashtag\": \"unknown\",\n      \"message_count\": 1,\n      \"last_active\": \"2026-09-22T09:02:36.460793Z\"\n    },\n    {\n      \"unknown\": false,\n      \"hashtag\": \"#fb-maçı\",\n      \"message_count\": 1,\n      \"last_active\": \"2026-09-22T09:02:35.745944Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Archive room",
          "event": [],
          "response": [],
          "request": {
            "method": "PUT",
            "header": [],
            "description": "204. Into MY archive only (others unaffected); archived rooms send me no pushes.",
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/archive",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "archive"
              ]
            }
          }
        },
        {
          "name": "Unarchive room",
          "event": [],
          "response": [],
          "request": {
            "method": "DELETE",
            "header": [],
            "description": "204. Back to the main list.",
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/archive",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "archive"
              ]
            }
          }
        },
        {
          "name": "Mute room",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/mute",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "mute"
              ]
            },
            "description": "204 — bu odadan bana push gelmez."
          }
        },
        {
          "name": "Unmute room",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/mute",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "mute"
              ]
            },
            "description": "204"
          }
        },
        {
          "name": "Room media (photo messages)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/media?limit=60",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "media"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "60"
                }
              ]
            },
            "description": "Odanın fotoğraf mesajları, yeniden eskiye. from=<account_id> yalnız onunkiler (profil Medya sekmesi), before=<ms>."
          }
        },
        {
          "name": "Presign photo message",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/media/presign",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "media",
                "presign"
              ]
            },
            "description": "full + thumb presigned PUT; mesaj sonra socket'te message:new {attachment} ile gider.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"content_type\": \"image/jpeg\"\n}"
            }
          }
        },
        {
          "name": "Presign document",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"content_type\": \"application/pdf\",\n  \"name\": \"sözleşme.pdf\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/docs/presign",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "docs",
                "presign"
              ]
            },
            "description": "Bir belge için tek presigned PUT. pdf/doc(x)/xls(x)/ppt(x)/zip, max 10 MB. Dosya gönderimden 1 saat sonra sunucudan silinir; satır kalır."
          },
          "response": []
        },
        {
          "name": "Document download link",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/docs/{{messageId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "docs",
                "{{messageId}}"
              ]
            },
            "description": "Bir belgeye taze presigned indirme linki, dosya sunucuda durdukça. Presigned link kendi başına 1 saatte bayatlar; linkini kaybeden cihaz buraya sorar. Belge gittiyse 410 document_expired."
          },
          "response": []
        },
        {
          "name": "Share article to dictionary (sözlük)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/messages/{{messageId}}/share",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "messages",
                "{{messageId}}",
                "share"
              ]
            },
            "description": "Yazar **kendi** makalesini sözlüğe yollar. Gövde yok.\n\nSözlük yönetim konsolunun veritabanında yaşar: topikler, moderatörler ve kuyruk oradadır; makaleler ve hesaplar burada. Makroskop \"bu gerçekten onun makalesi\" der, konsol \"bu etiketin topiği var ve bu yazar yasaklı değil\" der — özel docker ağında tek çağrı.\n\n`SOZLUK_BASE_URL` + `SOZLUK_INTERNAL_TOKEN` ayarlı değilse uç yokmuş gibi **404** döner (admin uçlarıyla aynı kapı).\n\nEtiket topiğin adıdır: kartın `hashtag`'i, yoksa mesajın ilk `hashtags` değeri; `#` atılır, küçük harfe iner. Etiket yoksa `422 sozluk_tag_required`.\n\n**201 = kuyrukta, yayımlandı değil** — kararı bir moderatör verir.\n\nHatalar: `403 not_author` (başkasının makalesi), `422 not_an_article`, `422 sozluk_topic_not_found` (mesaj konsoldan gelir, hangi etiket olduğunu söyler), `403 sozluk_author_banned` (mesaj moderatörün gerekçesidir), `409 sozluk_already_shared`, `503 sozluk_unavailable` (konsola ulaşılamadı — ret değil, tekrar denenecek arıza)."
          },
          "response": [
            {
              "name": "201 queued for a moderator",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{accessToken}}"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/messages/{{messageId}}/share",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "messages",
                    "{{messageId}}",
                    "share"
                  ]
                },
                "description": "Yazar **kendi** makalesini sözlüğe yollar. Gövde yok.\n\nSözlük yönetim konsolunun veritabanında yaşar: topikler, moderatörler ve kuyruk oradadır; makaleler ve hesaplar burada. Makroskop \"bu gerçekten onun makalesi\" der, konsol \"bu etiketin topiği var ve bu yazar yasaklı değil\" der — özel docker ağında tek çağrı.\n\n`SOZLUK_BASE_URL` + `SOZLUK_INTERNAL_TOKEN` ayarlı değilse uç yokmuş gibi **404** döner (admin uçlarıyla aynı kapı).\n\nEtiket topiğin adıdır: kartın `hashtag`'i, yoksa mesajın ilk `hashtags` değeri; `#` atılır, küçük harfe iner. Etiket yoksa `422 sozluk_tag_required`.\n\n**201 = kuyrukta, yayımlandı değil** — kararı bir moderatör verir.\n\nHatalar: `403 not_author` (başkasının makalesi), `422 not_an_article`, `422 sozluk_topic_not_found` (mesaj konsoldan gelir, hangi etiket olduğunu söyler), `403 sozluk_author_banned` (mesaj moderatörün gerekçesidir), `409 sozluk_already_shared`, `503 sozluk_unavailable` (konsola ulaşılamadı — ret değil, tekrar denenecek arıza)."
              },
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"share\": {\n    \"entry_id\": \"7f1c0b3e-2a41-4c88-9d55-1e0a7c6b34df\",\n    \"status\": \"pending\",\n    \"topic\": {\n      \"tag\": \"gündem\",\n      \"title\": \"Gündem\"\n    },\n    \"message\": \"Yazın sözlük kuyruğuna alındı; bir moderatör onaylayınca yayımlanacak.\"\n  }\n}"
            },
            {
              "name": "403 somebody else's article",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{accessToken}}"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/messages/{{messageId}}/share",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "messages",
                    "{{messageId}}",
                    "share"
                  ]
                },
                "description": "Yazar **kendi** makalesini sözlüğe yollar. Gövde yok.\n\nSözlük yönetim konsolunun veritabanında yaşar: topikler, moderatörler ve kuyruk oradadır; makaleler ve hesaplar burada. Makroskop \"bu gerçekten onun makalesi\" der, konsol \"bu etiketin topiği var ve bu yazar yasaklı değil\" der — özel docker ağında tek çağrı.\n\n`SOZLUK_BASE_URL` + `SOZLUK_INTERNAL_TOKEN` ayarlı değilse uç yokmuş gibi **404** döner (admin uçlarıyla aynı kapı).\n\nEtiket topiğin adıdır: kartın `hashtag`'i, yoksa mesajın ilk `hashtags` değeri; `#` atılır, küçük harfe iner. Etiket yoksa `422 sozluk_tag_required`.\n\n**201 = kuyrukta, yayımlandı değil** — kararı bir moderatör verir.\n\nHatalar: `403 not_author` (başkasının makalesi), `422 not_an_article`, `422 sozluk_topic_not_found` (mesaj konsoldan gelir, hangi etiket olduğunu söyler), `403 sozluk_author_banned` (mesaj moderatörün gerekçesidir), `409 sozluk_already_shared`, `503 sozluk_unavailable` (konsola ulaşılamadı — ret değil, tekrar denenecek arıza)."
              },
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"not_author\",\n    \"message\": \"Only the author can do that.\"\n  }\n}"
            },
            {
              "name": "422 no topic for that tag",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{accessToken}}"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/messages/{{messageId}}/share",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "messages",
                    "{{messageId}}",
                    "share"
                  ]
                },
                "description": "Yazar **kendi** makalesini sözlüğe yollar. Gövde yok.\n\nSözlük yönetim konsolunun veritabanında yaşar: topikler, moderatörler ve kuyruk oradadır; makaleler ve hesaplar burada. Makroskop \"bu gerçekten onun makalesi\" der, konsol \"bu etiketin topiği var ve bu yazar yasaklı değil\" der — özel docker ağında tek çağrı.\n\n`SOZLUK_BASE_URL` + `SOZLUK_INTERNAL_TOKEN` ayarlı değilse uç yokmuş gibi **404** döner (admin uçlarıyla aynı kapı).\n\nEtiket topiğin adıdır: kartın `hashtag`'i, yoksa mesajın ilk `hashtags` değeri; `#` atılır, küçük harfe iner. Etiket yoksa `422 sozluk_tag_required`.\n\n**201 = kuyrukta, yayımlandı değil** — kararı bir moderatör verir.\n\nHatalar: `403 not_author` (başkasının makalesi), `422 not_an_article`, `422 sozluk_topic_not_found` (mesaj konsoldan gelir, hangi etiket olduğunu söyler), `403 sozluk_author_banned` (mesaj moderatörün gerekçesidir), `409 sozluk_already_shared`, `503 sozluk_unavailable` (konsola ulaşılamadı — ret değil, tekrar denenecek arıza)."
              },
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"sozluk_topic_not_found\",\n    \"message\": \"#gündem için açılmış bir topik yok.\"\n  }\n}"
            },
            {
              "name": "403 the writer is banned",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{accessToken}}"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/messages/{{messageId}}/share",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "messages",
                    "{{messageId}}",
                    "share"
                  ]
                },
                "description": "Yazar **kendi** makalesini sözlüğe yollar. Gövde yok.\n\nSözlük yönetim konsolunun veritabanında yaşar: topikler, moderatörler ve kuyruk oradadır; makaleler ve hesaplar burada. Makroskop \"bu gerçekten onun makalesi\" der, konsol \"bu etiketin topiği var ve bu yazar yasaklı değil\" der — özel docker ağında tek çağrı.\n\n`SOZLUK_BASE_URL` + `SOZLUK_INTERNAL_TOKEN` ayarlı değilse uç yokmuş gibi **404** döner (admin uçlarıyla aynı kapı).\n\nEtiket topiğin adıdır: kartın `hashtag`'i, yoksa mesajın ilk `hashtags` değeri; `#` atılır, küçük harfe iner. Etiket yoksa `422 sozluk_tag_required`.\n\n**201 = kuyrukta, yayımlandı değil** — kararı bir moderatör verir.\n\nHatalar: `403 not_author` (başkasının makalesi), `422 not_an_article`, `422 sozluk_topic_not_found` (mesaj konsoldan gelir, hangi etiket olduğunu söyler), `403 sozluk_author_banned` (mesaj moderatörün gerekçesidir), `409 sozluk_already_shared`, `503 sozluk_unavailable` (konsola ulaşılamadı — ret değil, tekrar denenecek arıza)."
              },
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"sozluk_author_banned\",\n    \"message\": \"Yinelenen içerik nedeniyle yasaklısın.\"\n  }\n}"
            },
            {
              "name": "409 already in the queue",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{accessToken}}"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/messages/{{messageId}}/share",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "messages",
                    "{{messageId}}",
                    "share"
                  ]
                },
                "description": "Yazar **kendi** makalesini sözlüğe yollar. Gövde yok.\n\nSözlük yönetim konsolunun veritabanında yaşar: topikler, moderatörler ve kuyruk oradadır; makaleler ve hesaplar burada. Makroskop \"bu gerçekten onun makalesi\" der, konsol \"bu etiketin topiği var ve bu yazar yasaklı değil\" der — özel docker ağında tek çağrı.\n\n`SOZLUK_BASE_URL` + `SOZLUK_INTERNAL_TOKEN` ayarlı değilse uç yokmuş gibi **404** döner (admin uçlarıyla aynı kapı).\n\nEtiket topiğin adıdır: kartın `hashtag`'i, yoksa mesajın ilk `hashtags` değeri; `#` atılır, küçük harfe iner. Etiket yoksa `422 sozluk_tag_required`.\n\n**201 = kuyrukta, yayımlandı değil** — kararı bir moderatör verir.\n\nHatalar: `403 not_author` (başkasının makalesi), `422 not_an_article`, `422 sozluk_topic_not_found` (mesaj konsoldan gelir, hangi etiket olduğunu söyler), `403 sozluk_author_banned` (mesaj moderatörün gerekçesidir), `409 sozluk_already_shared`, `503 sozluk_unavailable` (konsola ulaşılamadı — ret değil, tekrar denenecek arıza)."
              },
              "status": "Conflict",
              "code": 409,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"sozluk_already_shared\",\n    \"message\": \"This article is already waiting in the dictionary's queue.\"\n  }\n}"
            },
            {
              "name": "503 the dictionary couldn't be reached",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{accessToken}}"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/messages/{{messageId}}/share",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "messages",
                    "{{messageId}}",
                    "share"
                  ]
                },
                "description": "Yazar **kendi** makalesini sözlüğe yollar. Gövde yok.\n\nSözlük yönetim konsolunun veritabanında yaşar: topikler, moderatörler ve kuyruk oradadır; makaleler ve hesaplar burada. Makroskop \"bu gerçekten onun makalesi\" der, konsol \"bu etiketin topiği var ve bu yazar yasaklı değil\" der — özel docker ağında tek çağrı.\n\n`SOZLUK_BASE_URL` + `SOZLUK_INTERNAL_TOKEN` ayarlı değilse uç yokmuş gibi **404** döner (admin uçlarıyla aynı kapı).\n\nEtiket topiğin adıdır: kartın `hashtag`'i, yoksa mesajın ilk `hashtags` değeri; `#` atılır, küçük harfe iner. Etiket yoksa `422 sozluk_tag_required`.\n\n**201 = kuyrukta, yayımlandı değil** — kararı bir moderatör verir.\n\nHatalar: `403 not_author` (başkasının makalesi), `422 not_an_article`, `422 sozluk_topic_not_found` (mesaj konsoldan gelir, hangi etiket olduğunu söyler), `403 sozluk_author_banned` (mesaj moderatörün gerekçesidir), `409 sozluk_already_shared`, `503 sozluk_unavailable` (konsola ulaşılamadı — ret değil, tekrar denenecek arıza)."
              },
              "status": "Service Unavailable",
              "code": 503,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"sozluk_unavailable\",\n    \"message\": \"The dictionary couldn't be reached — try again.\"\n  }\n}"
            }
          ]
        },
        {
          "name": "Room photos",
          "event": [],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "avatars"
              ]
            },
            "description": "Every photo of the room, the primary one first. Open to anyone signed in, like *Room by slug*. `url` (1:1 original) and `thumb_url` are presigned GETs, valid 1 h — clients keep the thumbnail bytes in their own cache and paint those first, so a stale URL costs a refresh, not a blank avatar."
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "avatars"
                  ]
                },
                "description": "Every photo of the room, the primary one first. Open to anyone signed in, like *Room by slug*. `url` (1:1 original) and `thumb_url` are presigned GETs, valid 1 h — clients keep the thumbnail bytes in their own cache and paint those first, so a stale URL costs a refresh, not a blank avatar."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"avatars\": [\n    {\n      \"id\": \"3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8\",\n      \"is_primary\": true,\n      \"content_type\": \"image/jpeg\",\n      \"size_px\": 1024,\n      \"byte_size\": 118432,\n      \"created_at\": \"2026-09-23T09:14:02.118374Z\",\n      \"url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=a41f0d6b2c8e5379d04b1fa6c39e82d7501b6ac4f8e39d2b7061ac5f4e93d281\",\n      \"thumb_url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8-thumb.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=c73e90ab15d648f2091cb73ea56d4802f19ce64b83d71a05f2ec9b14d70a6538\"\n    },\n    {\n      \"id\": \"b1c04f8e-2a77-4d53-8c19-6f7a5e0d2b34\",\n      \"is_primary\": false,\n      \"content_type\": \"image/jpeg\",\n      \"size_px\": 1024,\n      \"byte_size\": 96117,\n      \"created_at\": \"2026-09-23T09:02:47.902551Z\",\n      \"url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/b1c04f8e-2a77-4d53-8c19-6f7a5e0d2b34.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=e80b2f57a1c94d6308fe2b5a9c71d4630af28e15b9c04d73e6a1f85b2d907c34\",\n      \"thumb_url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/b1c04f8e-2a77-4d53-8c19-6f7a5e0d2b34-thumb.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=1d5a8e07c3b96f24e70a1d8b5c293f46a08d7e21b5c49f036e8a2d71c04b5f89\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Presign room photo",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const b = pm.response.json();",
                  "if (b.full) {",
                  "  pm.collectionVariables.set(\"roomAvatarKey\", b.full.key);",
                  "  pm.collectionVariables.set(\"roomAvatarThumbKey\", b.thumb.key);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars/presign",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "avatars",
                "presign"
              ]
            },
            "description": "Admins only. Two presigned PUTs that share one id: `full` (the 1:1 original) and `thumb` (the small copy clients cache). `content_type` defaults to `image/jpeg`; `image/png` and `image/webp` are the other two, anything else is `400 invalid_request`. The client crops to a square, uploads **both** objects itself, then registers the keys with *Add room photo*. Saves both keys.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"content_type\": \"image/jpeg\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars/presign",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "avatars",
                    "presign"
                  ]
                },
                "description": "Admins only. Two presigned PUTs that share one id: `full` (the 1:1 original) and `thumb` (the small copy clients cache). `content_type` defaults to `image/jpeg`; `image/png` and `image/webp` are the other two, anything else is `400 invalid_request`. The client crops to a square, uploads **both** objects itself, then registers the keys with *Add room photo*. Saves both keys.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"content_type\": \"image/jpeg\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"content_type\": \"image/jpeg\",\n  \"full\": {\n    \"method\": \"PUT\",\n    \"key\": \"room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8.jpg\",\n    \"url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=5c1b0a7f4e3d29c8b6a5f4e3d2c1b0a9f8e7d6c5b4a3928170f6e5d4c3b2a190\",\n    \"expires_in\": 600\n  },\n  \"thumb\": {\n    \"method\": \"PUT\",\n    \"key\": \"room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8-thumb.jpg\",\n    \"url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8-thumb.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544\",\n    \"expires_in\": 600\n  }\n}"
            },
            {
              "name": "400 unsupported type",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars/presign",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "avatars",
                    "presign"
                  ]
                },
                "description": "Admins only. Two presigned PUTs that share one id: `full` (the 1:1 original) and `thumb` (the small copy clients cache). `content_type` defaults to `image/jpeg`; `image/png` and `image/webp` are the other two, anything else is `400 invalid_request`. The client crops to a square, uploads **both** objects itself, then registers the keys with *Add room photo*. Saves both keys.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"content_type\": \"image/gif\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Bad Request",
              "code": 400,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"invalid_request\",\n    \"message\": \"Missing or malformed parameters.\"\n  }\n}"
            },
            {
              "name": "403 not an admin",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars/presign",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "avatars",
                    "presign"
                  ]
                },
                "description": "Admins only. Two presigned PUTs that share one id: `full` (the 1:1 original) and `thumb` (the small copy clients cache). `content_type` defaults to `image/jpeg`; `image/png` and `image/webp` are the other two, anything else is `400 invalid_request`. The client crops to a square, uploads **both** objects itself, then registers the keys with *Add room photo*. Saves both keys.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"content_type\": \"image/jpeg\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"not_admin\",\n    \"message\": \"Only the room's admins can do that.\"\n  }\n}"
            }
          ]
        },
        {
          "name": "Add room photo",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const b = pm.response.json();",
                  "if (b.avatar) pm.collectionVariables.set(\"roomAvatarId\", b.avatar.id);"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "avatars"
              ]
            },
            "description": "Admins only. Registers the two objects you just PUT to MinIO. The first photo — and every later one you don't send `\"primary\": false` for — becomes the room's face; the old primary steps aside in the same transaction. Square photos, max 4096 px and 8 MB. Saves roomAvatarId.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"key\": \"{{roomAvatarKey}}\",\n  \"thumb_key\": \"{{roomAvatarThumbKey}}\",\n  \"content_type\": \"image/jpeg\",\n  \"size_px\": 1024,\n  \"byte_size\": 118432,\n  \"primary\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "avatars"
                  ]
                },
                "description": "Admins only. Registers the two objects you just PUT to MinIO. The first photo — and every later one you don't send `\"primary\": false` for — becomes the room's face; the old primary steps aside in the same transaction. Square photos, max 4096 px and 8 MB. Saves roomAvatarId.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"key\": \"{{roomAvatarKey}}\",\n  \"thumb_key\": \"{{roomAvatarThumbKey}}\",\n  \"content_type\": \"image/jpeg\",\n  \"size_px\": 1024,\n  \"byte_size\": 118432,\n  \"primary\": true\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"avatar\": {\n    \"id\": \"3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8\",\n    \"is_primary\": true,\n    \"content_type\": \"image/jpeg\",\n    \"size_px\": 1024,\n    \"byte_size\": 118432,\n    \"created_at\": \"2026-09-23T09:14:02.118374Z\",\n    \"url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=a41f0d6b2c8e5379d04b1fa6c39e82d7501b6ac4f8e39d2b7061ac5f4e93d281\",\n    \"thumb_url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8-thumb.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=c73e90ab15d648f2091cb73ea56d4802f19ce64b83d71a05f2ec9b14d70a6538\"\n  }\n}"
            },
            {
              "name": "422 missing thumbnail",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "avatars"
                  ]
                },
                "description": "Admins only. Registers the two objects you just PUT to MinIO. The first photo — and every later one you don't send `\"primary\": false` for — becomes the room's face; the old primary steps aside in the same transaction. Square photos, max 4096 px and 8 MB. Saves roomAvatarId.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"key\": \"{{roomAvatarKey}}\",\n  \"size_px\": 1024\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"validation_failed\",\n    \"message\": \"Some fields are invalid.\",\n    \"fields\": {\n      \"thumb_key\": [\n        \"can't be blank\"\n      ]\n    }\n  }\n}"
            }
          ]
        },
        {
          "name": "Make photo primary",
          "event": [],
          "request": {
            "method": "PUT",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars/{{roomAvatarId}}/primary",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "avatars",
                "{{roomAvatarId}}",
                "primary"
              ]
            },
            "description": "Admins only. Picks which photo the room shows in lists and headers; the old primary steps aside. A photo that isn't this room's → `404 not_found`."
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "PUT",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars/{{roomAvatarId}}/primary",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "avatars",
                    "{{roomAvatarId}}",
                    "primary"
                  ]
                },
                "description": "Admins only. Picks which photo the room shows in lists and headers; the old primary steps aside. A photo that isn't this room's → `404 not_found`."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"avatar\": {\n    \"id\": \"3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8\",\n    \"is_primary\": true,\n    \"content_type\": \"image/jpeg\",\n    \"size_px\": 1024,\n    \"byte_size\": 118432,\n    \"created_at\": \"2026-09-23T09:14:02.118374Z\",\n    \"url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=a41f0d6b2c8e5379d04b1fa6c39e82d7501b6ac4f8e39d2b7061ac5f4e93d281\",\n    \"thumb_url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8-thumb.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=c73e90ab15d648f2091cb73ea56d4802f19ce64b83d71a05f2ec9b14d70a6538\"\n  }\n}"
            },
            {
              "name": "404 unknown photo",
              "originalRequest": {
                "method": "PUT",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars/{{roomAvatarId}}/primary",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "avatars",
                    "{{roomAvatarId}}",
                    "primary"
                  ]
                },
                "description": "Admins only. Picks which photo the room shows in lists and headers; the old primary steps aside. A photo that isn't this room's → `404 not_found`."
              },
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"not_found\",\n    \"message\": \"Not found.\"\n  }\n}"
            }
          ]
        },
        {
          "name": "Delete room photo",
          "event": [],
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/avatars/{{roomAvatarId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "avatars",
                "{{roomAvatarId}}"
              ]
            },
            "description": "204. Admins only. Drops the row and both MinIO objects; if it was the primary one, the next photo takes over. Unknown id → `404 not_found`."
          },
          "response": []
        },
        {
          "name": "Buy short link (vanity)",
          "event": [],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/vanity-link",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "vanity-link"
              ]
            },
            "description": "Spends the buyer's stars (`vanity_link_star_cost`, 10 by default) to move the room onto `<link_prefix>/<code>` — `short_link` follows immediately. The stars land on the room and raise its `level`. Admins only (`403 not_admin`), empty wallet → `402 insufficient_stars`, a code another room already took → `422`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"code\": \"gece-kulubu\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/vanity-link",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "vanity-link"
                  ]
                },
                "description": "Spends the buyer's stars (`vanity_link_star_cost`, 10 by default) to move the room onto `<link_prefix>/<code>` — `short_link` follows immediately. The stars land on the room and raise its `level`. Admins only (`403 not_admin`), empty wallet → `402 insufficient_stars`, a code another room already took → `422`.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"code\": \"gece-kulubu\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"room\": {\n    \"id\": \"9b7e579c-01a6-4bb4-9ef7-876b9217f58e\",\n    \"name\": \"Fenerbahçe Taraftar g4fqi\",\n    \"created_at\": \"2026-09-22T09:02:35.365549Z\",\n    \"slug\": \"fenerbahce-taraftar-g4fqi\",\n    \"star_gated\": false,\n    \"founder_id\": \"f6cad462-5db2-444f-9347-f1ac20b68431\",\n    \"visibility\": \"open\",\n    \"welcome_message\": null,\n    \"message_ttl_hours\": null,\n    \"level\": 1,\n    \"stars\": 10,\n    \"gifts\": 0,\n    \"vanity_code\": \"gece-kulubu\",\n    \"wallpaper\": \"okyanus\", \"short_link\": \"mskp.io/gece-kulubu\",\n    \"member_count\": 2,\n    \"is_member\": true,\n    \"avatar\": {\n      \"id\": \"3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8\",\n      \"is_primary\": true,\n      \"content_type\": \"image/jpeg\",\n      \"size_px\": 1024,\n      \"byte_size\": 118432,\n      \"created_at\": \"2026-09-23T09:14:02.118374Z\",\n      \"url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=a41f0d6b2c8e5379d04b1fa6c39e82d7501b6ac4f8e39d2b7061ac5f4e93d281\",\n      \"thumb_url\": \"http://localhost:9000/makroskop-media/room-avatars/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/3f2a9c41-5d0e-4c6b-9a77-0b3f6d21e4c8-thumb.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260923%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260923T091402Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=c73e90ab15d648f2091cb73ea56d4802f19ce64b83d71a05f2ec9b14d70a6538\"\n    }\n  }\n}"
            },
            {
              "name": "402 not enough stars",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/vanity-link",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "vanity-link"
                  ]
                },
                "description": "Spends the buyer's stars (`vanity_link_star_cost`, 10 by default) to move the room onto `<link_prefix>/<code>` — `short_link` follows immediately. The stars land on the room and raise its `level`. Admins only (`403 not_admin`), empty wallet → `402 insufficient_stars`, a code another room already took → `422`.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"code\": \"gece-kulubu\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Payment Required",
              "code": 402,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"insufficient_stars\",\n    \"message\": \"Not enough stars for this purchase.\"\n  }\n}"
            },
            {
              "name": "422 code already taken",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/vanity-link",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "rooms",
                    "{{roomSlug}}",
                    "vanity-link"
                  ]
                },
                "description": "Spends the buyer's stars (`vanity_link_star_cost`, 10 by default) to move the room onto `<link_prefix>/<code>` — `short_link` follows immediately. The stars land on the room and raise its `level`. Admins only (`403 not_admin`), empty wallet → `402 insufficient_stars`, a code another room already took → `422`.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"code\": \"tek\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"error\": {\n    \"code\": \"validation_failed\",\n    \"message\": \"Some fields are invalid.\",\n    \"fields\": {\n      \"vanity_code\": [\n        \"has already been taken\"\n      ]\n    }\n  }\n}"
            }
          ]
        },
        {
          "name": "Room stats",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/stats",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "stats"
              ]
            },
            "description": "Grup sayfasının üst şeridi."
          }
        },
        {
          "name": "Room activity",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/events?limit=50",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "events"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                }
              ]
            },
            "description": "Grup hareketleri: katılma, ayrılma, ban, susturma."
          }
        },
        {
          "name": "Join requests",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/requests",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "requests"
              ]
            },
            "description": "Kapıda bekleyenler (join_approval açıkken). Adminler."
          }
        },
        {
          "name": "Approve request",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/requests/{{peerAccountId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "requests",
                "{{peerAccountId}}"
              ]
            },
            "description": ""
          }
        },
        {
          "name": "Reject request",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/requests/{{peerAccountId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "requests",
                "{{peerAccountId}}"
              ]
            },
            "description": ""
          }
        },
        {
          "name": "Bans",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/bans",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "bans"
              ]
            },
            "description": ""
          }
        },
        {
          "name": "Unban",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/bans/{{peerAccountId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "bans",
                "{{peerAccountId}}"
              ]
            },
            "description": ""
          }
        },
        {
          "name": "Remove member (kick)",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/members/{{peerAccountId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "members",
                "{{peerAccountId}}"
              ]
            },
            "description": ""
          }
        },
        {
          "name": "Make admin",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/admins/{{peerAccountId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "admins",
                "{{peerAccountId}}"
              ]
            },
            "description": "Yalnız kurucu."
          }
        },
        {
          "name": "Remove admin",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}/admins/{{peerAccountId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}",
                "admins",
                "{{peerAccountId}}"
              ]
            },
            "description": ""
          }
        },
        {
          "name": "Delete room",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/rooms/{{roomSlug}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "rooms",
                "{{roomSlug}}"
              ]
            },
            "description": "Yalnız kurucu; geri dönüşü yok."
          }
        }
      ]
    },
    {
      "name": "People & DM",
      "item": [
        {
          "name": "Contacts",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/contacts",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "contacts"
              ]
            },
            "description": "Ataçtaki kişi kartı listesi: takip ettiklerin + birebir sohbet ettiklerin. Telefon rehberi okunmaz."
          },
          "response": []
        },
        {
          "name": "User profile",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/users/{{peerUsername}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "users",
                "{{peerUsername}}"
              ]
            },
            "description": "{user, common_rooms, dm} — profil kartı: kim, ortak gruplar, varsa **aramızdaki ve benim de üyesi olduğum** DM odası. Karşı taraf sohbeti açıp hiçbir şey yazmadıysa `dm: null` gelir."
          }
        },
        {
          "name": "Open DM",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/dm/{{peerUsername}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "dm",
                "{{peerUsername}}"
              ]
            },
            "description": "O kişiyle birebir oda (`kind: dm`); yoksa açılır. Oda karşı tarafın rumuzuyla döner (`peer` alanı). **Yalnız açanı üye yapar:** karşı tarafın listesinde hiçbir şey belirmez, onlara `GET /api/users/:username` `dm: null` der. Karşı taraf odaya — ve `room:new` kendi hattına — **ilk mesaj yazıldığında** düşer."
          }
        },
        {
          "name": "Follow",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/users/{{peerUsername}}/follow",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "users",
                "{{peerUsername}}",
                "follow"
              ]
            },
            "description": ""
          }
        },
        {
          "name": "Unfollow",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/users/{{peerUsername}}/follow",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "users",
                "{{peerUsername}}",
                "follow"
              ]
            },
            "description": ""
          }
        },
        {
          "name": "Their followers",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/users/{{peerUsername}}/followers",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "users",
                "{{peerUsername}}",
                "followers"
              ]
            },
            "description": ""
          }
        },
        {
          "name": "Who they follow",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{accessToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/users/{{peerUsername}}/following",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "users",
                "{{peerUsername}}",
                "following"
              ]
            },
            "description": ""
          }
        }
      ]
    },
    {
      "name": "Uploads",
      "description": "Presigned MinIO URLs — the app uploads bytes directly to storage with a PUT to `upload.url` (valid 10 min).",
      "item": [
        {
          "name": "Presign avatar",
          "event": [],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/uploads/presign",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "uploads",
                "presign"
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"kind\": \"avatar\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/uploads/presign",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "uploads",
                    "presign"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"kind\": \"avatar\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"upload\": {\n    \"key\": \"avatars/f6cad462-5db2-444f-9347-f1ac20b68431.webp\",\n    \"url\": \"http://localhost:9000/makroskop-media/avatars/f6cad462-5db2-444f-9347-f1ac20b68431.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260922%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260922T090237Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=3ff8d97e341810af096c4a870de2bacfeb87a9eb42b43af704bcd0d469f0e08f\",\n    \"method\": \"PUT\",\n    \"expires_in\": 600\n  }\n}"
            }
          ]
        },
        {
          "name": "Presign room media",
          "event": [],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/uploads/presign",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "uploads",
                "presign"
              ]
            },
            "description": "Members only. Filename is sanitised.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"kind\": \"room_media\",\n  \"room_slug\": \"{{roomSlug}}\",\n  \"filename\": \"gol.jpg\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/uploads/presign",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "uploads",
                    "presign"
                  ]
                },
                "description": "Members only. Filename is sanitised.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"kind\": \"room_media\",\n  \"room_slug\": \"<slug>\",\n  \"filename\": \"gol anı.jpg\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"upload\": {\n    \"key\": \"room-media/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/1e82d899-7c76-4eca-afb4-10fc2d32a1b4/gol_an__.jpg\",\n    \"url\": \"http://localhost:9000/makroskop-media/room-media/9b7e579c-01a6-4bb4-9ef7-876b9217f58e/1e82d899-7c76-4eca-afb4-10fc2d32a1b4/gol_an__.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<minio_access_key>%2F20260922%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260922T090237Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=a75d3e691de9ea7536fe3bd9c581dde18e81f9aef5616b7fb137044277aa79c7\",\n    \"method\": \"PUT\",\n    \"expires_in\": 600\n  }\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Admin console",
      "description": "Yönetim konsolunun (ayrı bir Go servisi) salt okunur penceresi: kim var (`Users`) ve panonun sayıları (`Stats`). Kimlik bir kişinin access token'ı değil, sunucuya verilen ortak sırdır: `Authorization: Bearer {{adminToken}}` (`ADMIN_API_TOKEN`).\n\nSır ayarlı değilse uçlar **404** döner — uç yokmuş gibi; yanlış sır `401 unauthorized`. `adminToken` koleksiyon değişkenine kendi sırrını yaz.\n\nTers yön (konsolun değil, Makroskop'un çağırdığı): bir yazar makalesini sözlüğe gönderdiğinde backend konsola gider — *Rooms → Share article to dictionary (sözlük)*, `SOZLUK_BASE_URL` + `SOZLUK_INTERNAL_TOKEN`.",
      "item": [
        {
          "name": "Users",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{adminToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/admin/users?q=&status=&platform=&sort=created_at&dir=desc&limit=50&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "admin",
                "users"
              ],
              "query": [
                {
                  "key": "q",
                  "value": "",
                  "description": "rumuzda geçen parça, büyük/küçük harf duyarsız"
                },
                {
                  "key": "status",
                  "value": "",
                  "description": "online | offline — başka değer ya da boş: süzgeç yok"
                },
                {
                  "key": "platform",
                  "value": "",
                  "description": "ios | android | web_app | web_integration (| web) — o platformda en az bir oturumu olan hesap; web_app/web_integration ve eski yazım web bugün kimseyi tutmaz"
                },
                {
                  "key": "sort",
                  "value": "created_at",
                  "description": "created_at (varsayılan) | username — rumuzsuzlar iki yönde de sonda"
                },
                {
                  "key": "dir",
                  "value": "desc",
                  "description": "desc (varsayılan) | asc"
                },
                {
                  "key": "limit",
                  "value": "50",
                  "description": "varsayılan 50, max 200"
                },
                {
                  "key": "offset",
                  "value": "0",
                  "description": "varsayılan 0"
                }
              ]
            },
            "description": "Bir sayfa hesap, varsayılan yeniden eskiye. `q` rumuzda geçen parçayı arar (büyük/küçük harf duyarsız), `limit` varsayılan 50 / max 200, `offset` varsayılan 0.\n\n`status` (`online`/`offline`), `platform` (`ios`/`android`/`web_app`/`web_integration`) ve `q` aynı sorguda birleşir; `total` her zaman süzgeçlerden geçen kümeyi sayar — konsol sayfalamayı ona göre çizer. `status` Presence'tan gelir (tabloda değil), `platform` hesabın `account_sessions` satırlarına bakar; web istemcisi olmadığı için `web_app`/`web_integration` (ve eski yazım `web`) bugün kimseyi tutmaz ama reddedilmez. `sort` `username` | `created_at` (varsayılan), `dir` `asc` | `desc` (varsayılan); rumuzsuz hesaplar iki yönde de en sonda, son eşitlik bozucu `account_id`. Tanınmayan değer hata değildir: süzgeç uygulanmaz / varsayılana düşülür.\n\n`online`/`state` Phoenix Presence'tan gelir (konsol onu göremez), `devices` hesabın oturumlarıdır — `platform`, `device_model`, `device_manufacturer` cihaz söylemediyse `null`, `is_emulator` üç durumlu (`true`/`false`/`null`, sonuncusu \"söylemedi\"). Hepsi `PUT /api/me/device`'tan gelir ve push kaydını beklemez; `push` yalnız token var mı der, token'ın kendisi sunucuda kalır. `accounts.identity_hash` ve ondan türeyen hiçbir şey dönmez; yalnız `identity_linked`.",
            "auth": {
              "type": "noauth"
            }
          },
          "response": [
            {
              "name": "200 one page of accounts",
              "originalRequest": {
                "method": "GET",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{adminToken}}"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/admin/users?q=&status=&platform=&sort=created_at&dir=desc&limit=50&offset=0",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "admin",
                    "users"
                  ],
                  "query": [
                    {
                      "key": "q",
                      "value": "",
                      "description": "rumuzda geçen parça, büyük/küçük harf duyarsız"
                    },
                    {
                      "key": "status",
                      "value": "",
                      "description": "online | offline — başka değer ya da boş: süzgeç yok"
                    },
                    {
                      "key": "platform",
                      "value": "",
                      "description": "ios | android | web_app | web_integration (| web) — o platformda en az bir oturumu olan hesap; web_app/web_integration ve eski yazım web bugün kimseyi tutmaz"
                    },
                    {
                      "key": "sort",
                      "value": "created_at",
                      "description": "created_at (varsayılan) | username — rumuzsuzlar iki yönde de sonda"
                    },
                    {
                      "key": "dir",
                      "value": "desc",
                      "description": "desc (varsayılan) | asc"
                    },
                    {
                      "key": "limit",
                      "value": "50",
                      "description": "varsayılan 50, max 200"
                    },
                    {
                      "key": "offset",
                      "value": "0",
                      "description": "varsayılan 0"
                    }
                  ]
                },
                "description": "Bir sayfa hesap, varsayılan yeniden eskiye. `q` rumuzda geçen parçayı arar (büyük/küçük harf duyarsız), `limit` varsayılan 50 / max 200, `offset` varsayılan 0.\n\n`status` (`online`/`offline`), `platform` (`ios`/`android`/`web_app`/`web_integration`) ve `q` aynı sorguda birleşir; `total` her zaman süzgeçlerden geçen kümeyi sayar — konsol sayfalamayı ona göre çizer. `status` Presence'tan gelir (tabloda değil), `platform` hesabın `account_sessions` satırlarına bakar; web istemcisi olmadığı için `web_app`/`web_integration` (ve eski yazım `web`) bugün kimseyi tutmaz ama reddedilmez. `sort` `username` | `created_at` (varsayılan), `dir` `asc` | `desc` (varsayılan); rumuzsuz hesaplar iki yönde de en sonda, son eşitlik bozucu `account_id`. Tanınmayan değer hata değildir: süzgeç uygulanmaz / varsayılana düşülür.\n\n`online`/`state` Phoenix Presence'tan gelir (konsol onu göremez), `devices` hesabın oturumlarıdır — `platform`, `device_model`, `device_manufacturer` cihaz söylemediyse `null`, `is_emulator` üç durumlu (`true`/`false`/`null`, sonuncusu \"söylemedi\"). Hepsi `PUT /api/me/device`'tan gelir ve push kaydını beklemez; `push` yalnız token var mı der, token'ın kendisi sunucuda kalır. `accounts.identity_hash` ve ondan türeyen hiçbir şey dönmez; yalnız `identity_linked`.",
                "auth": {
                  "type": "noauth"
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"users\": [\n    {\n      \"account_id\": \"57b1cf1d-6763-45ed-9f03-2b8276dac094\",\n      \"username\": \"ayse\",\n      \"avatar\": {\n        \"id\": \"f61c56f9-cf75-4261-9f29-935602400ae6\",\n        \"is_primary\": true,\n        \"content_type\": \"image/jpeg\",\n        \"size_px\": 1024,\n        \"byte_size\": 118432,\n        \"created_at\": \"2026-09-25T08:25:16.255793Z\",\n        \"url\": \"https://media.makroskop.app/…&X-Amz-Signature=…\",\n        \"thumb_url\": \"https://media.makroskop.app/…-thumb.jpg?…&X-Amz-Signature=…\"\n      },\n      \"premium\": false,\n      \"identity_linked\": true,\n      \"birth_date\": \"1990-01-01\",\n      \"gender\": \"female\",\n      \"bio\": null,\n      \"created_at\": 1790324716228,\n      \"online\": true,\n      \"state\": \"online\",\n      \"devices\": [\n        {\n          \"platform\": \"ios\",\n          \"device_model\": \"iPhone16,2\",\n          \"device_manufacturer\": \"Apple\",\n          \"is_emulator\": false,\n          \"last_used_at\": null,\n          \"created_at\": 1790324716241,\n          \"push\": true\n        }\n      ]\n    },\n    {\n      \"account_id\": \"73533d6a-48d9-40df-89ef-831f0945dd96\",\n      \"username\": \"mehmet\",\n      \"avatar\": null,\n      \"premium\": false,\n      \"identity_linked\": false,\n      \"birth_date\": null,\n      \"gender\": null,\n      \"bio\": null,\n      \"created_at\": 1790324716258,\n      \"online\": false,\n      \"state\": null,\n      \"devices\": [\n        {\n          \"platform\": null,\n          \"device_model\": null,\n          \"device_manufacturer\": null,\n          \"is_emulator\": null,\n          \"last_used_at\": null,\n          \"created_at\": 1790324716259,\n          \"push\": false\n        }\n      ]\n    }\n  ],\n  \"total\": 2\n}"
            }
          ]
        },
        {
          "name": "Stats",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{adminToken}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/admin/stats",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "admin",
                "stats"
              ]
            },
            "description": "Panonun çizdiği sayılar; süzgeci yok.\n\n`people.total` **hesap** sayar, oturum değil — ve `platforms`, `environments`, `models` de öyle: üç cihazı olan biri üç kişi kadar ağır basmasın. Bir hesap sahip olduğu her ayrı platform için bir kez sayılır, o yüzden dilimlerin toplamı `total`'den büyük olabilir. `online` Phoenix Presence'tan gelir (konsol onu göremez), `offline` geri kalanıdır.\n\nHiç oturumu olmayan ya da hiçbir şey söylememiş hesap `unknown`'a düşer. **Her anahtar her cevapta vardır**, sıfır olsa bile — grafiğin dilimleri her tazelemede belirip kaybolmasın. `environments` `is_emulator`'ın üç durumudur: `physical` = `false`, `emulator` = `true`, `unknown` = söylemedi.\n\n`models` en çok taşınan 10 model (eşitlikte ada göre); modelini söylemeyen cihaz burada hiç yoktur — uydurma bir \"unknown\" satırı gerçek donanım listesinde gerçek sanılırdı.\n\n`rooms.dms` birebir oda sayısıdır ve oda zaten çift başına tek satır olduğu için her sohbet tam bir kez sayılır. `messages` silinmemiş ve üst düzey mesajlardır (tartışma yanıtı köküne aittir); `group_messages + dm_messages == messages`.",
            "auth": {
              "type": "noauth"
            }
          },
          "response": [
            {
              "name": "200 dashboard numbers",
              "originalRequest": {
                "method": "GET",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{adminToken}}"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/api/admin/stats",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "api",
                    "admin",
                    "stats"
                  ]
                },
                "description": "Panonun çizdiği sayılar; süzgeci yok.\n\n`people.total` **hesap** sayar, oturum değil — ve `platforms`, `environments`, `models` de öyle: üç cihazı olan biri üç kişi kadar ağır basmasın. Bir hesap sahip olduğu her ayrı platform için bir kez sayılır, o yüzden dilimlerin toplamı `total`'den büyük olabilir. `online` Phoenix Presence'tan gelir (konsol onu göremez), `offline` geri kalanıdır.\n\nHiç oturumu olmayan ya da hiçbir şey söylememiş hesap `unknown`'a düşer. **Her anahtar her cevapta vardır**, sıfır olsa bile — grafiğin dilimleri her tazelemede belirip kaybolmasın. `environments` `is_emulator`'ın üç durumudur: `physical` = `false`, `emulator` = `true`, `unknown` = söylemedi.\n\n`models` en çok taşınan 10 model (eşitlikte ada göre); modelini söylemeyen cihaz burada hiç yoktur — uydurma bir \"unknown\" satırı gerçek donanım listesinde gerçek sanılırdı.\n\n`rooms.dms` birebir oda sayısıdır ve oda zaten çift başına tek satır olduğu için her sohbet tam bir kez sayılır. `messages` silinmemiş ve üst düzey mesajlardır (tartışma yanıtı köküne aittir); `group_messages + dm_messages == messages`.",
                "auth": {
                  "type": "noauth"
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json; charset=utf-8"
                }
              ],
              "body": "{\n  \"people\": {\n    \"total\": 186,\n    \"online\": 3,\n    \"offline\": 183,\n    \"platforms\": [\n      {\n        \"key\": \"android\",\n        \"count\": 42\n      },\n      {\n        \"key\": \"ios\",\n        \"count\": 11\n      },\n      {\n        \"key\": \"web_app\",\n        \"count\": 0\n      },\n      {\n        \"key\": \"web_integration\",\n        \"count\": 0\n      },\n      {\n        \"key\": \"unknown\",\n        \"count\": 133\n      }\n    ],\n    \"environments\": [\n      {\n        \"key\": \"physical\",\n        \"count\": 30\n      },\n      {\n        \"key\": \"emulator\",\n        \"count\": 12\n      },\n      {\n        \"key\": \"unknown\",\n        \"count\": 144\n      }\n    ],\n    \"models\": [\n      {\n        \"label\": \"iPhone16,2\",\n        \"manufacturer\": \"Apple\",\n        \"count\": 8\n      },\n      {\n        \"label\": \"SM-S911B\",\n        \"manufacturer\": \"samsung\",\n        \"count\": 5\n      }\n    ]\n  },\n  \"rooms\": {\n    \"groups\": 12,\n    \"dms\": 40,\n    \"messages\": 5321,\n    \"group_messages\": 4000,\n    \"dm_messages\": 1321\n  }\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Socket (Phoenix Channels)",
      "description": "WebSocket (Phoenix Channels v2). Postman's collection format can't hold WebSocket requests, so create one in the workspace: **New → WebSocket**, URL:\n\n`{{wsUrl}}/socket/websocket?token={{accessToken}}&vsn=2.0.0`\n\nEvery frame is a JSON array: `[join_ref, ref, topic, event, payload]`. Send `phx_join` first, then any client event. Keep the connection alive with a heartbeat every 30 s: `[null, \"hb\", \"phoenix\", \"heartbeat\", {}]`.\n\nReady-to-paste messages (replace the slug):\n\n```\n[\"1\",\"1\",\"room:<slug>\",\"phx_join\",{}]\n[\"1\",\"2\",\"room:<slug>\",\"message:new\",{\"body\":\"Maç ne oldu? #fb-maçı\",\"client_ref\":\"c-1\"}]\n[\"1\",\"3\",\"room:<slug>\",\"message:ack\",{\"ids\":[\"<message_id>\"]}]\n[\"1\",\"4\",\"room:<slug>\",\"typing\",{}]\n[\"1\",\"5\",\"room:<slug>\",\"presence:set\",{\"state\":\"background\"}]\n[\"1\",\"6\",\"room:<slug>\",\"presence:set\",{\"state\":\"online\"}]\n[null,\"hb\",\"phoenix\",\"heartbeat\",{}]\n```\n\nKendi hattı (`user:<account_id>`) — `[\"1\",\"7\",\"user:<account_id>\",\"phx_join\",{}]` — şunları taşır: `room:new`, `room:gone`, `room:activity` `{slug, seq, last_message, mentioned}` (oda kapalıyken listenin satırı), `peer:presence` ve `account:avatar` `{account_id, avatar}`. Mesajlar `mentions: [{account_id, username}]` ile gelir: gövdedeki `@rumuz`lardan odada gerçekten olanlar.\n\nFull event list with real captured outputs: documents/07-api.md in the repo.",
      "item": []
    }
  ]
}
