{
  "openapi": "3.0.3",
  "info": {
    "title": "KardGrabber API",
    "version": "1.0.0",
    "description": "HTTP API for KardGrabber company workspaces and business cards. Login and register are anonymous. Every other operation needs Authorization: Bearer token. Card operations use the caller's current company. This document contains no credentials."
  },
  "servers": [
    { "url": "/" }
  ],
  "tags": [
    { "name": "Auth" },
    { "name": "Companies" },
    { "name": "Cards" },
    { "name": "Extraction" }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "Token returned by login or register. Send it as Authorization: Bearer <token>."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": { "type": "string" }
        }
      },
      "User": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "email": { "type": "string", "format": "email" },
          "displayName": { "type": "string" }
        }
      },
      "Company": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "string" },
          "description": { "type": "string", "nullable": true, "description": "Workspace blurb. This is not the scanned company summary." },
          "role": { "type": "string", "enum": ["Owner", "Member"] },
          "canAddCards": { "type": "boolean" },
          "canEditCards": { "type": "boolean" },
          "canDeleteCards": { "type": "boolean" },
          "canManageMembers": { "type": "boolean" },
          "isCurrent": { "type": "boolean" },
          "memberCount": { "type": "integer" }
        }
      },
      "CompanyBriefing": {
        "type": "object",
        "description": "Workspace briefing used by later card lookups. Empty fields are null. This is not a scanned company's summary.",
        "properties": {
          "id": { "type": "integer" },
          "offering": { "type": "string", "nullable": true, "description": "What the caller's company does. Up to 2000 characters." },
          "targetCustomers": { "type": "string", "nullable": true, "description": "Who they sell to. Up to 500 characters." },
          "companyQuestions": { "type": "string", "nullable": true, "description": "What to learn about the scanned company. Up to 2000 characters." },
          "personQuestions": { "type": "string", "nullable": true, "description": "What to learn about the person. Up to 2000 characters." },
          "hasBriefing": { "type": "boolean" },
          "canEdit": { "type": "boolean", "description": "True when the caller is an owner." }
        }
      },
      "CompanyBriefingRequest": {
        "type": "object",
        "description": "Replaces the whole briefing. Send every field to keep. An empty or missing field clears that part.",
        "properties": {
          "offering": { "type": "string", "nullable": true, "maxLength": 2000 },
          "targetCustomers": { "type": "string", "nullable": true, "maxLength": 500 },
          "companyQuestions": { "type": "string", "nullable": true, "maxLength": 2000 },
          "personQuestions": { "type": "string", "nullable": true, "maxLength": 2000 }
        }
      },
      "CardText": {
        "type": "object",
        "properties": {
          "fullName": { "type": "string", "nullable": true },
          "jobTitle": { "type": "string", "nullable": true },
          "companyName": { "type": "string", "nullable": true },
          "companyAcronym": { "type": "string", "nullable": true },
          "officeLocation": { "type": "string", "nullable": true },
          "email": { "type": "string", "nullable": true },
          "phone": { "type": "string", "nullable": true },
          "mobile": { "type": "string", "nullable": true },
          "website": { "type": "string", "nullable": true },
          "address": { "type": "string", "nullable": true },
          "notes": { "type": "string", "nullable": true },
          "linkedInUrl": { "type": "string", "nullable": true },
          "companyLinkedInUrl": { "type": "string", "nullable": true }
        }
      },
      "BriefFact": {
        "type": "object",
        "properties": {
          "text": { "type": "string" },
          "source": { "type": "string", "enum": ["card", "website", "search", "linkedin"] }
        }
      },
      "BriefAssumption": {
        "type": "object",
        "properties": {
          "text": { "type": "string" },
          "because": { "type": "string", "nullable": true }
        }
      },
      "CardBrief": {
        "type": "object",
        "description": "Version 1 meeting brief. Card responses do not return this object directly. They return it as the briefJson string. Clients should prefer the top-level companySummary and personSummary, and use this document for talking points, relevance, stated facts, and inferences.",
        "properties": {
          "schemaVersion": { "type": "integer", "example": 1 },
          "companySummary": { "type": "string", "nullable": true },
          "companyTalkingPoints": { "type": "array", "items": { "type": "string" } },
          "companyRelevance": { "type": "string", "nullable": true },
          "personSummary": { "type": "string", "nullable": true },
          "personStated": { "type": "array", "items": { "$ref": "#/components/schemas/BriefFact" } },
          "personAssumed": { "type": "array", "items": { "$ref": "#/components/schemas/BriefAssumption" } },
          "personTalkingPoints": { "type": "array", "items": { "type": "string" } },
          "linkedInReadable": { "type": "boolean", "nullable": true },
          "briefingApplied": { "type": "boolean", "nullable": true }
        }
      },
      "FieldComparison": {
        "type": "object",
        "properties": {
          "field": { "type": "string" },
          "cardValue": { "type": "string", "nullable": true },
          "webValue": { "type": "string", "nullable": true },
          "status": { "type": "string", "enum": ["match", "corrected", "conflict", "web_only", "card_only", "missing"] },
          "recommended": { "type": "string", "nullable": true }
        }
      },
      "DiscoveredLink": {
        "type": "object",
        "properties": {
          "title": { "type": "string" },
          "url": { "type": "string" },
          "type": { "type": "string" }
        }
      },
      "DuplicateMatch": {
        "type": "object",
        "properties": {
          "duplicate": { "type": "boolean" },
          "exactMatch": { "type": "boolean" },
          "sameCompany": { "type": "boolean" },
          "canMerge": { "type": "boolean" },
          "existingId": { "type": "integer" },
          "message": { "type": "string" },
          "existingCard": { "$ref": "#/components/schemas/DuplicateCard" }
        }
      },
      "DuplicateCard": {
        "allOf": [
          { "$ref": "#/components/schemas/CardText" },
          {
            "type": "object",
            "description": "The stored card inside a duplicate match. imagePath and backImagePath are stored file paths, not absolute URLs.",
            "properties": {
              "id": { "type": "integer" },
              "imagePath": { "type": "string", "nullable": true },
              "backImagePath": { "type": "string", "nullable": true }
            }
          }
        ]
      },
      "CardListItem": {
        "type": "object",
        "description": "Card row returned by the list. It has no notes, LinkedIn URLs, brief, or enrichment document.",
        "properties": {
          "id": { "type": "integer" },
          "fullName": { "type": "string", "nullable": true },
          "jobTitle": { "type": "string", "nullable": true },
          "companyName": { "type": "string", "nullable": true },
          "companyAcronym": { "type": "string", "nullable": true },
          "officeLocation": { "type": "string", "nullable": true },
          "email": { "type": "string", "nullable": true },
          "phone": { "type": "string", "nullable": true },
          "mobile": { "type": "string", "nullable": true },
          "website": { "type": "string", "nullable": true },
          "address": { "type": "string", "nullable": true },
          "imagePath": { "type": "string", "nullable": true },
          "backImagePath": { "type": "string", "nullable": true },
          "imageUrl": { "type": "string", "nullable": true, "description": "Absolute URL, or null when there is no front image." },
          "backImageUrl": { "type": "string", "nullable": true, "description": "Absolute URL, or null when there is no back image." },
          "createdAt": { "type": "string", "format": "date-time" },
          "updatedAt": { "type": "string", "format": "date-time" }
        }
      },
      "Card": {
        "type": "object",
        "description": "One saved card. Prefer companySummary and personSummary for the short text. briefJson is a JSON string for talking points, relevance, stated facts, and inferences.",
        "properties": {
          "id": { "type": "integer" },
          "fullName": { "type": "string", "nullable": true },
          "jobTitle": { "type": "string", "nullable": true },
          "companyName": { "type": "string", "nullable": true },
          "companyAcronym": { "type": "string", "nullable": true },
          "officeLocation": { "type": "string", "nullable": true },
          "email": { "type": "string", "nullable": true },
          "phone": { "type": "string", "nullable": true },
          "mobile": { "type": "string", "nullable": true },
          "website": { "type": "string", "nullable": true },
          "address": { "type": "string", "nullable": true },
          "notes": { "type": "string", "nullable": true, "description": "System field written during enrichment. Not the list of notes people add." },
          "userNotes": {
            "type": "array",
            "description": "Notes people added, oldest first.",
            "items": { "$ref": "#/components/schemas/CardUserNote" }
          },
          "links": {
            "type": "array",
            "description": "Website, LinkedIn, and other saved links. A removed link is absent. checked means a person marked it right, which is separate from website verification.",
            "items": { "$ref": "#/components/schemas/CardLink" }
          },
          "linkedInUrl": { "type": "string", "nullable": true },
          "companyLinkedInUrl": { "type": "string", "nullable": true },
          "imagePath": { "type": "string", "nullable": true },
          "backImagePath": { "type": "string", "nullable": true },
          "imageUrl": { "type": "string", "nullable": true, "description": "Absolute URL, or null when there is no front image." },
          "backImageUrl": { "type": "string", "nullable": true, "description": "Absolute URL, or null when there is no back image." },
          "rawOcrText": { "type": "string", "nullable": true },
          "createdAt": { "type": "string", "format": "date-time" },
          "updatedAt": { "type": "string", "format": "date-time" },
          "enrichedAt": { "type": "string", "format": "date-time", "nullable": true },
          "enrichmentJson": { "type": "string", "nullable": true, "description": "JSON string, not an object. Website-check document kept alongside the brief." },
          "companySummary": { "type": "string", "nullable": true, "description": "Company paragraph. Prefer this over parsing briefJson." },
          "personSummary": { "type": "string", "nullable": true, "description": "One line about the person. Prefer this over parsing briefJson." },
          "briefJson": {
            "type": "string",
            "nullable": true,
            "description": "JSON string, not an object. Parse it as CardBrief (#/components/schemas/CardBrief). Prefer the top-level companySummary and personSummary. Use the brief for talking points, relevance, stated facts, and inferences."
          },
          "canEditCards": { "type": "boolean" },
          "canDeleteCards": { "type": "boolean" }
        }
      },
      "MemberRole": {
        "type": "string",
        "description": "Named access level. The server stores Owner or Member and derives this label from the permission flags. Custom is not a role and is never returned.",
        "enum": ["Owner", "FullAccess", "Contributor", "Viewer"]
      },
      "MemberPermissionTemplate": {
        "type": "string",
        "description": "How to set the three card flags. Custom applies canAddCards, canEditCards, and canDeleteCards only when role is not Owner, FullAccess, Contributor, or Viewer. A named role overrides this template.",
        "enum": ["FullAccess", "Contributor", "Viewer", "Custom"]
      },
      "CompanyMember": {
        "type": "object",
        "properties": {
          "membershipId": { "type": "integer" },
          "userId": { "type": "string" },
          "email": { "type": "string" },
          "displayName": { "type": "string", "nullable": true },
          "role": { "type": "string", "enum": ["Owner", "Member"], "description": "Stored company role. Permission level is userRole plus the three flags." },
          "userRole": { "$ref": "#/components/schemas/MemberRole" },
          "canAddCards": { "type": "boolean" },
          "canEditCards": { "type": "boolean" },
          "canDeleteCards": { "type": "boolean" },
          "permissionSummary": { "type": "string" },
          "joinedAt": { "type": "string", "format": "date-time" }
        }
      },
      "CompanyMemberList": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "string" },
          "members": { "type": "array", "items": { "$ref": "#/components/schemas/CompanyMember" } }
        }
      },
      "MemberWrite": {
        "type": "object",
        "description": "role is Owner, FullAccess, Contributor, or Viewer. Custom is not stored. Sending role Custom without template Custom is saved as Full access. To set the three flags, send template Custom and a role that is not one of those four names. The request value Custom is accepted for that check only; the stored role is still Member, and the response role is the derived label.",
        "properties": {
          "role": { "type": "string", "description": "Owner, FullAccess, Contributor, or Viewer. Do not send Custom as a stored role." },
          "template": { "$ref": "#/components/schemas/MemberPermissionTemplate" },
          "canAddCards": { "type": "boolean" },
          "canEditCards": { "type": "boolean" },
          "canDeleteCards": { "type": "boolean" }
        }
      },
      "AddMemberRequest": {
        "allOf": [
          { "$ref": "#/components/schemas/MemberWrite" },
          {
            "type": "object",
            "required": ["email"],
            "properties": {
              "email": { "type": "string", "format": "email" }
            }
          }
        ]
      },
      "MemberChangeResult": {
        "type": "object",
        "description": "role here is the derived label (MemberRole), not the stored Owner/Member value.",
        "properties": {
          "membershipId": { "type": "integer" },
          "email": { "type": "string", "description": "Present when a member is added. Omitted when a role is updated." },
          "role": { "$ref": "#/components/schemas/MemberRole" },
          "message": { "type": "string" }
        }
      },
      "CardUserNote": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "text": { "type": "string" },
          "createdAt": { "type": "string", "format": "date-time" },
          "createdByUserId": { "type": "string" },
          "createdByName": { "type": "string", "nullable": true }
        }
      },
      "AddCardNoteRequest": {
        "type": "object",
        "required": ["text"],
        "properties": {
          "text": { "type": "string", "maxLength": 4000, "description": "Required. Cannot be empty or longer than 4000 characters." }
        }
      },
      "CardLink": {
        "type": "object",
        "properties": {
          "field": { "type": "string", "enum": ["website", "linkedInUrl", "companyLinkedInUrl", "twitterUrl", "facebookUrl", "other"] },
          "label": { "type": "string" },
          "url": { "type": "string" },
          "checked": { "type": "boolean", "description": "True after a person marks the link right. This is not website verification." },
          "checkedByName": { "type": "string", "nullable": true }
        }
      },
      "CardLinkRequest": {
        "type": "object",
        "required": ["field", "action"],
        "properties": {
          "field": { "type": "string", "enum": ["website", "linkedInUrl", "companyLinkedInUrl", "twitterUrl", "facebookUrl", "other"] },
          "action": { "type": "string", "enum": ["update", "remove", "check"] },
          "url": { "type": "string", "nullable": true, "description": "Replacement URL. Required when action is update." },
          "currentUrl": { "type": "string", "nullable": true, "description": "The link being changed. Required when field is other." }
        }
      },
      "CardLinkList": {
        "type": "object",
        "properties": {
          "links": { "type": "array", "items": { "$ref": "#/components/schemas/CardLink" } }
        }
      }
    }
  },
  "paths": {
    "/api/auth/login": {
      "post": {
        "tags": ["Auth"],
        "summary": "Log in and receive a bearer token",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "password"],
                "properties": {
                  "email": { "type": "string", "format": "email" },
                  "password": { "type": "string" }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Token, expiry, user, currentCompany, and companies." },
          "401": { "description": "Email or password did not match.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/auth/register": {
      "post": {
        "tags": ["Auth"],
        "summary": "Register and receive a bearer token",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["displayName", "email", "password"],
                "properties": {
                  "displayName": { "type": "string" },
                  "email": { "type": "string", "format": "email" },
                  "password": { "type": "string", "minLength": 6, "description": "At least 6 characters and one digit." }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Same body as login. The new user has no company yet." },
          "400": { "description": "Validation failed or the email is already registered.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/auth/forgot-password": {
      "post": {
        "tags": ["Auth"],
        "summary": "Email a one-hour password reset link",
        "description": "Anonymous. The response does not reveal whether the email is registered. On a development server with no SMTP settings, the body also includes resetUrl.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email"],
                "properties": {
                  "email": { "type": "string", "format": "email" }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "{ message }. Development only: resetUrl when outgoing email is not configured." },
          "503": { "description": "Outgoing email is not configured or the message could not be sent.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/auth/reset-password": {
      "post": {
        "tags": ["Auth"],
        "summary": "Set a new password with a reset token",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "token", "newPassword"],
                "properties": {
                  "email": { "type": "string", "format": "email" },
                  "token": { "type": "string" },
                  "newPassword": { "type": "string", "minLength": 6, "description": "At least 6 characters and one digit." }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "{ message }. The password is updated." },
          "400": { "description": "The token is invalid or expired, or the password does not meet the rules.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/auth/me": {
      "get": {
        "tags": ["Auth"],
        "summary": "Current user, current company, and company list",
        "responses": { "200": { "description": "user, currentCompany, and companies. No new token." }, "401": { "description": "Missing or invalid token." } }
      }
    },
    "/api/companies": {
      "get": {
        "tags": ["Companies"],
        "summary": "List the caller's companies",
        "responses": { "200": { "description": "Array of Company, including isCurrent and memberCount." } }
      },
      "post": {
        "tags": ["Companies"],
        "summary": "Create a company and select it",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name"],
                "properties": {
                  "name": { "type": "string" },
                  "description": { "type": "string", "nullable": true }
                }
              }
            }
          }
        },
        "responses": { "200": { "description": "The new company. The caller is Owner and it is current." }, "400": { "description": "Name is missing." } }
      }
    },
    "/api/companies/switch": {
      "post": {
        "tags": ["Companies"],
        "summary": "Select the current company",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["companyId"],
                "properties": { "companyId": { "type": "integer" } }
              }
            }
          }
        },
        "responses": { "200": { "description": "{ companyId }" }, "403": { "description": "The user is not a member." } }
      }
    },
    "/api/companies/{id}/briefing": {
      "get": {
        "tags": ["Companies"],
        "summary": "Read a company briefing",
        "description": "Any member can read it. canEdit is true for an owner.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": {
          "200": { "description": "The briefing.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CompanyBriefing" } } } },
          "403": { "description": "The user is not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "description": "Company was not found." }
        }
      },
      "put": {
        "tags": ["Companies"],
        "summary": "Replace a company briefing",
        "description": "Owner only. Replaces all four fields. New card lookups use the saved text. Cards already looked up stay as they are until they are looked up again.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CompanyBriefingRequest" } } } },
        "responses": {
          "200": { "description": "The saved briefing. canEdit is true.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CompanyBriefing" } } } },
          "400": { "description": "A field is longer than its limit. Nothing is saved.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "Only the company owner can update the briefing.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "description": "Company was not found." }
        }
      }
    },
    "/api/companies/{id}/members": {
      "get": {
        "tags": ["Companies"],
        "summary": "List members",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": {
          "200": {
            "description": "Company id, name, and members. Owner only.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CompanyMemberList" } } }
          },
          "403": { "description": "Caller is not the owner.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "description": "Company was not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      },
      "post": {
        "tags": ["Companies"],
        "summary": "Add a registered user as a member",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AddMemberRequest" } } } },
        "responses": {
          "200": {
            "description": "membershipId, email, role, and message. role is the derived label.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MemberChangeResult" } } }
          },
          "400": { "description": "Unknown email, already a member, or the caller added themselves.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "Caller is not the owner.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "description": "Company was not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/companies/{id}/members/{membershipId}": {
      "put": {
        "tags": ["Companies"],
        "summary": "Change a member role",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
          { "name": "membershipId", "in": "path", "required": true, "schema": { "type": "integer" } }
        ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MemberWrite" } } } },
        "responses": {
          "200": {
            "description": "membershipId, role, and message. role is the derived label. email is omitted.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MemberChangeResult" } } }
          },
          "400": { "description": "The last owner would be demoted.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "Caller is not the owner.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "description": "Member was not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      },
      "delete": {
        "tags": ["Companies"],
        "summary": "Remove a member",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
          { "name": "membershipId", "in": "path", "required": true, "schema": { "type": "integer" } }
        ],
        "responses": {
          "200": { "description": "{ message }", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" } } } } } },
          "400": { "description": "Last owner, or the caller tried to remove themselves.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "Caller is not the owner.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "description": "Member was not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/cards": {
      "get": {
        "tags": ["Cards"],
        "summary": "List cards in the current company",
        "parameters": [{ "name": "search", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Matches name, company, or job title." }],
        "responses": {
          "200": {
            "description": "Short card list. Does not include briefJson or enrichmentJson.",
            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/CardListItem" } } } }
          },
          "400": { "description": "No company selected." }
        }
      },
      "post": {
        "tags": ["Cards"],
        "summary": "Create a card",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "allOf": [
                  { "$ref": "#/components/schemas/CardText" },
                  {
                    "type": "object",
                    "properties": {
                      "image": { "type": "string", "format": "binary" },
                      "imageBack": { "type": "string", "format": "binary" },
                      "rawOcrText": { "type": "string" },
                      "enrichmentJson": { "type": "string" },
                      "enrichedAt": { "type": "string" },
                      "localId": { "type": "string" },
                      "forceSave": { "type": "string", "description": "true saves even when a similar card exists." }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": { "201": { "description": "{ id }. Location is the website card page." }, "403": { "description": "No permission to add cards." }, "409": { "description": "Duplicate match. existingCard includes id, card text, imagePath, and backImagePath.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DuplicateMatch" } } } } }
      }
    },
    "/api/cards/{id}": {
      "get": {
        "tags": ["Cards"],
        "summary": "Get one card, including the brief and enrichment document",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": {
          "200": {
            "description": "Saved card. Prefer companySummary and personSummary. briefJson is a CardBrief JSON string for talking points, relevance, stated facts, and inferences.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Card" } } }
          },
          "404": { "description": "Not in the current company." }
        }
      },
      "put": {
        "tags": ["Cards"],
        "summary": "Replace card text fields",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CardText" } } } },
        "responses": { "200": { "description": "{ id }" }, "403": { "description": "No permission to edit cards." }, "404": { "description": "Not in the current company." } }
      },
      "delete": {
        "tags": ["Cards"],
        "summary": "Delete a card and its images",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": { "204": { "description": "Deleted." }, "403": { "description": "No permission to delete cards." }, "404": { "description": "Not in the current company." } }
      }
    },
    "/api/cards/{id}/notes": {
      "post": {
        "tags": ["Cards"],
        "summary": "Add a user note to a card",
        "description": "Adds one note written by a person. This does not change the system notes field. Requires permission to edit cards. There is no endpoint to edit or delete a note.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AddCardNoteRequest" } } } },
        "responses": {
          "201": { "description": "The saved note.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CardUserNote" } } } },
          "400": { "description": "Note text is missing or longer than 4000 characters.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "No permission to edit cards." },
          "404": { "description": "Not in the current company." }
        }
      }
    },
    "/api/cards/{id}/links": {
      "post": {
        "tags": ["Cards"],
        "summary": "Update, remove, or check one card link",
        "description": "Corrects one link. update replaces the URL, remove hides it for the whole company, and check marks it right for everyone. checked is not website verification. A removed or replaced link is not written back by POST /api/cards/{id}/enrich. Requires permission to edit cards. The response replaces the card's link list.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CardLinkRequest" } } } },
        "responses": {
          "200": { "description": "The card's links after the change.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CardLinkList" } } } },
          "400": { "description": "The field, action, or URL is not valid.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "No permission to edit cards." },
          "404": { "description": "Not in the current company." }
        }
      }
    },
    "/api/cards/sync": {
      "post": {
        "tags": ["Cards"],
        "summary": "Upload a card that was saved offline",
        "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/CardText" } } } },
        "responses": { "200": { "description": "{ id, localId, synced: true }" }, "409": { "description": "Duplicate match. existingCard includes id, card text, imagePath, and backImagePath.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DuplicateMatch" } } } } }
      }
    },
    "/api/cards/check-duplicate": {
      "post": {
        "tags": ["Cards"],
        "summary": "Check for a similar card in the current company",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CardText" } } } },
        "responses": { "200": { "description": "{ duplicate: false, sameCompany: true } or a DuplicateMatch." } }
      }
    },
    "/api/cards/merge": {
      "post": {
        "tags": ["Cards"],
        "summary": "Fill empty fields on an existing card and optionally replace images",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "allOf": [
                  { "$ref": "#/components/schemas/CardText" },
                  {
                    "type": "object",
                    "required": ["existingId"],
                    "properties": {
                      "existingId": { "type": "integer" },
                      "replaceFrontImage": { "type": "string" },
                      "replaceBackImage": { "type": "string" },
                      "image": { "type": "string", "format": "binary" },
                      "imageBack": { "type": "string", "format": "binary" },
                      "enrichmentJson": { "type": "string" },
                      "enrichedAt": { "type": "string" }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": { "200": { "description": "{ id, merged: true }" }, "403": { "description": "No permission to edit cards." } }
      }
    },
    "/api/cards/parse": {
      "post": {
        "tags": ["Extraction"],
        "deprecated": true,
        "summary": "Deprecated. Always returns 400.",
        "description": "Not part of the supported client contract. Rule-based parsing is disabled and this call always returns 400. Use POST /api/cards/ai-extract.",
        "responses": { "400": { "description": "Rule-based parsing is disabled.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }
      }
    },
    "/api/cards/ai-status": {
      "get": {
        "tags": ["Extraction"],
        "summary": "Whether AI extraction is available",
        "responses": { "200": { "description": "enabled, configured, hasApiKey, provider (the label AI), and slow. The credential is never returned." } }
      }
    },
    "/api/cards/ai-extract": {
      "post": {
        "tags": ["Extraction"],
        "summary": "Read card text from a photo without saving",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "imageBase64": { "type": "string" },
                  "contentType": { "type": "string" },
                  "imageBackBase64": { "type": "string", "nullable": true },
                  "backContentType": { "type": "string", "nullable": true }
                }
              }
            },
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "image": { "type": "string", "format": "binary" },
                  "imageBack": { "type": "string", "format": "binary" },
                  "imageBase64": { "type": "string" },
                  "imageBackBase64": { "type": "string" },
                  "contentType": { "type": "string" },
                  "backContentType": { "type": "string" }
                }
              }
            }
          }
        },
        "responses": { "200": { "description": "source ai plus card text fields, excluding notes and LinkedIn." }, "400": { "description": "AI is unavailable or the image is missing." } }
      }
    },
    "/api/cards/enrich-status": {
      "get": {
        "tags": ["Extraction"],
        "summary": "Whether website enrichment is available",
        "responses": { "200": { "description": "enabled, configured, and provider. provider is a short mode label, not a credential." } }
      }
    },
    "/api/cards/enrich": {
      "post": {
        "tags": ["Extraction"],
        "summary": "Look up a company website without saving",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CardText" } } } },
        "responses": { "200": { "description": "Links, companyDescription, companySummary, personSummary, briefJson, achievements, otherLinks, comparisons, corrected fields, enrichedAt, and enrichmentJson. briefJson is a CardBrief JSON string, not an object. This call does not save a card." }, "400": { "description": "Enrichment is disabled or the lookup failed." } }
      }
    },
    "/api/cards/linkedin-search": {
      "post": {
        "tags": ["Extraction"],
        "summary": "Search for a public LinkedIn profile URL",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CardText" } } } },
        "responses": { "200": { "description": "success, linkedInUrl, source, error, and searchedAt." }, "400": { "description": "fullName is missing or enrichment is disabled." } }
      }
    },
    "/api/cards/{id}/enrich": {
      "post": {
        "tags": ["Extraction"],
        "summary": "Enrich a saved card and store the result",
        "description": "Call this operation. It looks up the company and person from the saved card and writes the result onto that card. The connected mobile app uses POST.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": { "200": { "description": "Saved links, summaries, briefJson, achievements, otherLinks, enrichedAt, and enrichmentJson. briefJson is a CardBrief JSON string." }, "400": { "description": "Enrichment is disabled or the lookup failed." }, "403": { "description": "No permission to edit cards." }, "404": { "description": "Card was not found." } }
      },
      "get": {
        "tags": ["Extraction"],
        "summary": "Side effect: enrich a saved card and store the result",
        "description": "Same write as POST /api/cards/{id}/enrich. This GET runs the lookup and saves the card. Do not prefetch it. It remains so a client can fall back when a host blocks POST.",
        "x-side-effect": true,
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": { "200": { "description": "Same body as POST on this path." }, "400": { "description": "Enrichment is disabled or the lookup failed." }, "403": { "description": "No permission to edit cards." }, "404": { "description": "Card was not found." } }
      }
    }
  },
  "security": [
    { "bearerAuth": [] }
  ]
}
