openapi: 3.1.0
info:
  title: Shipnest Public API
  version: "1.0.0"
  description: |
    REST API למערכת Shipnest — מאפשר ל-ERP / חנויות / סקריפטים / פלטפורמות
    אוטומציה (n8n, Make) לקרוא ולעדכן משלוחים, לקוחות, ולנהל מנויי webhook.

    כל בקשה דורשת bearer token בפורמט `ship_live_…` ו-header `X-Timestamp`
    בטווח של ±5 דקות משעון השרת. כל ה-endpoints מחזירים envelope אחיד:
    `{ success, data?, error?, meta? }`.

    ה-tenant נגזר אוטומטית מהמפתח — אסור לציין אותו ב-URL או בגוף הבקשה.

    תיעוד מלא + מדריך אינטראקטיבי: `/portal/settings/api` בפורטל הלקוח.
  contact:
    name: Shipnest support
    url: https://shipnest.io
  license:
    name: Proprietary

servers:
  - url: "{baseUrl}/api/v1"
    description: ה-tenant של הלקוח
    variables:
      baseUrl:
        default: https://app.shipnest.io
        description: ה-host שעליו רץ ה-tenant שלכם

security:
  - bearerAuth: []

tags:
  - name: shipments
    description: יצירה, קריאה, עדכון וביטול של משלוחים
  - name: customers
    description: ניהול כרטיסי לקוח
  - name: webhooks
    description: ניהול מנויי outbound webhook

paths:
  /shipments:
    get:
      tags: [shipments]
      summary: רשימת משלוחים (פגינציה)
      description: ממוין מהחדש לישן. השתמשו ב-`meta.nextCursor` לדף הבא.
      parameters:
        - $ref: "#/components/parameters/XTimestamp"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: cursor
          in: query
          description: id מספרי של המשלוח האחרון בעמוד הקודם
          schema: { type: integer }
        - name: status
          in: query
          description: סינון לפי `sendStatus`
          schema: { type: string, enum: [PENDING, SUCCESS, FAILURE] }
        - name: createdFrom
          in: query
          schema: { type: string, format: date-time }
        - name: createdTo
          in: query
          schema: { type: string, format: date-time }
      security:
        - bearerAuth: [shipments:read]
      responses:
        "200":
          description: רשימת משלוחים
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Shipment" }
                      meta:
                        type: object
                        properties:
                          count: { type: integer }
                          nextCursor: { type: integer, nullable: true }
                          hasMore: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

    post:
      tags: [shipments]
      summary: יצירת משלוח חדש
      description: |
        יוצר משלוח **מקומי בלבד** — לא נשלח אוטומטית ל-Lionwheel. אם
        ה-tenant מסונכרן עם Lionwheel, מומלץ ליצור את המשלוח ישירות שם
        והוא יגיע אלינו דרך webhook.

        `barcode` ייחודי גלובלית — POST על barcode קיים מחזיר 409.
        מומלץ להתייחס ל-409 כתוצאה לגיטימית של upsert (idempotency).

        פולט אירוע `shipment.created`.
      parameters:
        - $ref: "#/components/parameters/XTimestamp"
      security:
        - bearerAuth: [shipments:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShipmentCreate" }
      responses:
        "201":
          description: המשלוח נוצר
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Shipment" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: ברקוד כבר קיים
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /shipments/{barcode}:
    parameters:
      - $ref: "#/components/parameters/Barcode"
      - $ref: "#/components/parameters/XTimestamp"

    get:
      tags: [shipments]
      summary: משלוח יחיד + עד 20 visits אחרונים
      security:
        - bearerAuth: [shipments:read]
      responses:
        "200":
          description: פרטי המשלוח
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data:
                        allOf:
                          - $ref: "#/components/schemas/Shipment"
                          - type: object
                            properties:
                              visits:
                                type: array
                                items: { $ref: "#/components/schemas/Visit" }
        "404": { $ref: "#/components/responses/NotFound" }

    patch:
      tags: [shipments]
      summary: עדכון חלקי של משלוח
      description: |
        עדכון שדות נבחרים. שדות שלא נשלחו נשמרים. שדות פנימיים
        (id / tenantId / barcode / createdAt / Lionwheel data) חסומים.

        אם שולחים `sendStatus` ששונה מהקיים — פולט `shipment.status_changed`.
      security:
        - bearerAuth: [shipments:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShipmentUpdate" }
      responses:
        "200":
          description: המשלוח עודכן
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Shipment" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /shipments/{barcode}/cancel:
    parameters:
      - $ref: "#/components/parameters/Barcode"
      - $ref: "#/components/parameters/XTimestamp"

    post:
      tags: [shipments]
      summary: ביטול משלוח (idempotent)
      description: |
        קובע `canceledAt`. קריאה שנייה לא משנה דבר — מחזירה את אותו state
        עם `meta.alreadyCancelled: true` ולא פולטת webhook נוסף.

        פולט `shipment.status_changed` עם `newStatus: "canceled"` בקריאה
        הראשונה בלבד. `reason` (אופציונלי) מתווסף ל-`orgNote`.
      security:
        - bearerAuth: [shipments:write]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                reason:
                  type: string
                  maxLength: 500
                  description: סיבת הביטול (אופציונלי). יתווסף ל-`orgNote`.
      responses:
        "200":
          description: בוטל (או היה כבר מבוטל)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Shipment" }
                      meta:
                        type: object
                        properties:
                          alreadyCancelled: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /customers:
    get:
      tags: [customers]
      summary: רשימת לקוחות (פגינציה + חיפוש)
      parameters:
        - $ref: "#/components/parameters/XTimestamp"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 100 }
        - name: cursor
          in: query
          schema: { type: string }
        - name: isActive
          in: query
          schema: { type: boolean }
        - name: search
          in: query
          description: חיפוש חופשי בשם / טלפון / אימייל
          schema: { type: string }
      security:
        - bearerAuth: [customers:read]
      responses:
        "200":
          description: רשימת לקוחות
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Customer" }
                      meta:
                        type: object
                        properties:
                          count: { type: integer }
                          nextCursor: { type: string, nullable: true }
                          hasMore: { type: boolean }

    post:
      tags: [customers]
      summary: יצירת לקוח חדש
      description: 'פולט אירוע `customer.created` עם `source: "api"`.'
      parameters:
        - $ref: "#/components/parameters/XTimestamp"
      security:
        - bearerAuth: [customers:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerCreate" }
      responses:
        "201":
          description: הלקוח נוצר
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Customer" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /customers/{id}:
    parameters:
      - $ref: "#/components/parameters/CustomerId"
      - $ref: "#/components/parameters/XTimestamp"

    get:
      tags: [customers]
      summary: כרטיס לקוח בודד
      security:
        - bearerAuth: [customers:read]
      responses:
        "200":
          description: פרטי הלקוח
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Customer" }
        "404": { $ref: "#/components/responses/NotFound" }

    patch:
      tags: [customers]
      summary: עדכון לקוח (partial)
      description: |
        ניתן לאפס שדות nullable עם `null` מפורש (למשל `{ "email": null }`).
        שדות פנימיים (lionwheelId, summitId, picking config) חסומים.
      security:
        - bearerAuth: [customers:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerUpdate" }
      responses:
        "200":
          description: הלקוח עודכן
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Customer" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks:
    get:
      tags: [webhooks]
      summary: רשימת ה-subscriptions של ה-tenant
      description: ה-secret של כל subscription **אינו** מוחזר ב-GET.
      parameters:
        - $ref: "#/components/parameters/XTimestamp"
      security:
        - bearerAuth: [webhooks:manage]
      responses:
        "200":
          description: רשימת subscriptions
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookSubscription" }
                      meta:
                        type: object
                        properties:
                          count: { type: integer }

    post:
      tags: [webhooks]
      summary: יצירת subscription
      description: |
        התשובה כוללת `secret` **פעם אחת בלבד**. שמרו אותו — לא ניתן יהיה
        לקרוא אותו שוב. ה-secret משמש לאימות HMAC-SHA256 של payloads
        יוצאים (`X-Webhook-Signature: t=…,v1=…`).
      parameters:
        - $ref: "#/components/parameters/XTimestamp"
      security:
        - bearerAuth: [webhooks:manage]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [name, url, events]
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
                url:
                  type: string
                  format: uri
                  maxLength: 500
                events:
                  type: array
                  minItems: 1
                  items: { $ref: "#/components/schemas/WebhookEventType" }
      responses:
        "201":
          description: ה-subscription נוצר
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data:
                        allOf:
                          - $ref: "#/components/schemas/WebhookSubscription"
                          - type: object
                            properties:
                              secret:
                                type: string
                                description: מוחזר פעם אחת בלבד
                                example: "whsec_a1b2c3d4e5f6789012345678901234567890abcd1234ef56"

  /webhooks/{id}:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
      - $ref: "#/components/parameters/XTimestamp"

    get:
      tags: [webhooks]
      summary: פרטי subscription בודד (ללא secret)
      security:
        - bearerAuth: [webhooks:manage]
      responses:
        "200":
          description: פרטי ה-subscription
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/WebhookSubscription" }
        "404": { $ref: "#/components/responses/NotFound" }

    patch:
      tags: [webhooks]
      summary: עדכון subscription (partial)
      security:
        - bearerAuth: [webhooks:manage]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              minProperties: 1
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
                url: { type: string, format: uri, maxLength: 500 }
                events:
                  type: array
                  minItems: 1
                  items: { $ref: "#/components/schemas/WebhookEventType" }
                isActive: { type: boolean }
      responses:
        "200":
          description: ה-subscription עודכן
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeSuccess"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/WebhookSubscription" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

    delete:
      tags: [webhooks]
      summary: מחיקת subscription
      security:
        - bearerAuth: [webhooks:manage]
      responses:
        "200":
          description: נמחק
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: true }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/{id}/test:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
      - $ref: "#/components/parameters/XTimestamp"

    post:
      tags: [webhooks]
      summary: שליחת test ping ל-URL הרשום
      description: |
        מוגבל ל-10 בקשות לדקה לכל מפתח (קצב נמוך יותר מ-default כי זו
        פעולה שמשפיעה על receiver חיצוני).

        התשובה מחזירה את ה-statusCode וה-body שהוחזרו מה-receiver. שימו לב:
        `success: true` כאן אומר שהבקשה הופצה — לא בהכרח שה-receiver
        החזיר 2xx. בדקו את `ok` או `statusCode`.
      security:
        - bearerAuth: [webhooks:manage]
      responses:
        "200":
          description: ה-ping נשלח (התוצאה ב-body)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  ok: { type: boolean }
                  statusCode: { type: integer, nullable: true }
                  message: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: ship_live_<token>
      description: |
        Bearer token שמונפק על ידי צוות Shipnest לכל tenant. הטוקן מוצג
        פעם אחת — אנחנו שומרים רק SHA-256 שלו. לכל מפתח רשימת scopes;
        בקשה ל-endpoint שדורש scope חסר תידחה ב-403.

  parameters:
    XTimestamp:
      name: X-Timestamp
      in: header
      required: true
      description: |
        Unix time (שניות או מילישניות) של מועד יצירת הבקשה. סטיה מותרת
        ±5 דקות משעון השרת. אנטי-replay.
      schema:
        type: integer
        example: 1714838400
    Barcode:
      name: barcode
      in: path
      required: true
      description: ברקוד המשלוח (ייחודי גלובלית)
      schema: { type: string, minLength: 1, maxLength: 100 }
    CustomerId:
      name: id
      in: path
      required: true
      schema: { type: string }
    WebhookId:
      name: id
      in: path
      required: true
      schema: { type: string }

  responses:
    BadRequest:
      description: בקשה לא תקינה (ולידציה נכשלה)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/EnvelopeError" }
    Unauthorized:
      description: חסר/לא תקין Bearer / X-Timestamp
      content:
        application/json:
          schema: { $ref: "#/components/schemas/EnvelopeError" }
    Forbidden:
      description: למפתח אין את ה-scope הנדרש
      content:
        application/json:
          schema: { $ref: "#/components/schemas/EnvelopeError" }
    NotFound:
      description: הישות לא נמצאה ב-tenant של המפתח
      content:
        application/json:
          schema: { $ref: "#/components/schemas/EnvelopeError" }
    RateLimited:
      description: חריגה ממכסת הבקשות (60/דקה לכל מפתח+נתיב)
      headers:
        Retry-After:
          schema: { type: integer }
          description: שניות עד שאפשר לנסות שוב
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/EnvelopeError" }

  schemas:
    EnvelopeSuccess:
      type: object
      required: [success]
      properties:
        success: { type: boolean, const: true }
        data: {}
        meta: { type: object }

    EnvelopeError:
      type: object
      required: [success, error]
      properties:
        success: { type: boolean, const: false }
        error: { type: string }
        code:
          type: string
          description: קוד מכונה אופציונלי (למשל TIMESTAMP_OUT_OF_WINDOW)

    Shipment:
      type: object
      properties:
        id: { type: integer }
        barcode: { type: string }
        createdAt: { type: string, format: date-time }
        customerName: { type: string }
        phone: { type: string }
        message: { type: string, nullable: true }
        sendStatus:
          type: string
          enum: [PENDING, SUCCESS, FAILURE]
        errorMessage: { type: string, nullable: true }
        destinationCity: { type: string }
        destinationStreet: { type: string }
        destinationNumber: { type: string }
        destinationFloor: { type: string, nullable: true }
        destinationApartment: { type: string, nullable: true }
        destinationNotes: { type: string, nullable: true }
        destinationEmail: { type: string, format: email, nullable: true }
        sourceCity: { type: string, nullable: true }
        sourceStreet: { type: string, nullable: true }
        sourceNumber: { type: string, nullable: true }
        sourcePhone: { type: string, nullable: true }
        packages: { type: integer, nullable: true }
        price:
          type: number
          format: float
          nullable: true
          description: בשקלים (במסד הנתונים נשמר כאגורות שלמות)
        weight: { type: number, format: float, nullable: true }
        urgency:
          type: string
          enum: [REGULAR, EXPRESS, URGENT]
          nullable: true
        leaveNextToDoor: { type: boolean, nullable: true }
        customerId: { type: string, nullable: true }
        externalOrderId: { type: string, nullable: true }
        regionCode: { type: string, nullable: true }
        pickingStatus: { type: string, nullable: true }
        taskType: { type: string, nullable: true }
        isRoundtrip: { type: boolean, nullable: true }
        roundtripStatus: { type: string, nullable: true }
        canceledAt: { type: string, format: date-time, nullable: true }

    ShipmentCreate:
      type: object
      additionalProperties: false
      required:
        - barcode
        - phone
        - customerName
        - sourceName
        - destinationCity
        - destinationStreet
        - destinationNumber
      properties:
        barcode: { type: string, minLength: 1, maxLength: 100 }
        phone:
          type: string
          minLength: 3
          maxLength: 30
          pattern: "^[\\d+\\-\\s()]+$"
        customerName: { type: string, minLength: 1, maxLength: 200 }
        sourceName: { type: string, minLength: 1, maxLength: 200 }
        destinationCity: { type: string, minLength: 1, maxLength: 100 }
        destinationStreet: { type: string, minLength: 1, maxLength: 200 }
        destinationNumber: { type: string, minLength: 1, maxLength: 20 }
        destinationFloor: { type: string, maxLength: 20 }
        destinationApartment: { type: string, maxLength: 20 }
        destinationNotes: { type: string, maxLength: 500 }
        destinationEmail: { type: string, format: email, maxLength: 254 }
        sourceCity: { type: string, maxLength: 100 }
        sourceStreet: { type: string, maxLength: 200 }
        sourceNumber: { type: string, maxLength: 20 }
        sourcePhone:
          type: string
          maxLength: 30
          pattern: "^[\\d+\\-\\s()]+$"
        message: { type: string, maxLength: 2000 }
        packages: { type: integer, minimum: 1, maximum: 999 }
        price:
          type: number
          minimum: 0
          maximum: 1000000
          description: בשקלים (decimal), נשמר ב-DB כאגורות
        weight: { type: number, minimum: 0, maximum: 10000 }
        urgency: { type: string, enum: [REGULAR, EXPRESS, URGENT] }
        leaveNextToDoor: { type: boolean }
        shipmentNote: { type: string, maxLength: 1000 }
        orgNote: { type: string, maxLength: 1000 }
        customerId: { type: string, minLength: 1, maxLength: 50 }
        externalOrderId: { type: string, maxLength: 100 }
        regionCode: { type: string, maxLength: 50 }

    ShipmentUpdate:
      type: object
      additionalProperties: false
      description: עדכון partial. שדות שלא נשלחו נשמרים. כל השדות אופציונליים.
      properties:
        phone:
          type: string
          minLength: 3
          maxLength: 30
          pattern: "^[\\d+\\-\\s()]+$"
        customerName: { type: string, minLength: 1, maxLength: 200 }
        sourceName: { type: string, minLength: 1, maxLength: 200 }
        destinationCity: { type: string, minLength: 1, maxLength: 100 }
        destinationStreet: { type: string, minLength: 1, maxLength: 200 }
        destinationNumber: { type: string, minLength: 1, maxLength: 20 }
        destinationFloor: { type: string, maxLength: 20 }
        destinationApartment: { type: string, maxLength: 20 }
        destinationNotes: { type: string, maxLength: 500 }
        destinationEmail: { type: string, format: email, maxLength: 254 }
        sourceCity: { type: string, maxLength: 100 }
        sourceStreet: { type: string, maxLength: 200 }
        sourceNumber: { type: string, maxLength: 20 }
        sourcePhone:
          type: string
          maxLength: 30
          pattern: "^[\\d+\\-\\s()]+$"
        message: { type: string, maxLength: 2000 }
        packages: { type: integer, minimum: 1, maximum: 999 }
        price: { type: number, minimum: 0, maximum: 1000000 }
        weight: { type: number, minimum: 0, maximum: 10000 }
        urgency: { type: string, enum: [REGULAR, EXPRESS, URGENT] }
        leaveNextToDoor: { type: boolean }
        shipmentNote: { type: string, maxLength: 1000 }
        orgNote: { type: string, maxLength: 1000 }
        sendStatus: { type: string, enum: [PENDING, SUCCESS, FAILURE] }
        errorMessage: { type: string, maxLength: 500, nullable: true }
        customerId: { type: string, minLength: 1, maxLength: 50, nullable: true }
        externalOrderId: { type: string, maxLength: 100 }

    Visit:
      type: object
      properties:
        id: { type: integer }
        kind: { type: string }
        visitAt: { type: string, format: date-time }
        isDone: { type: boolean }
        deliveredAt: { type: string, format: date-time, nullable: true }
        failedAt: { type: string, format: date-time, nullable: true }
        driverId: { type: integer, nullable: true }
        notes: { type: string, nullable: true }

    Customer:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        email: { type: string, format: email, nullable: true }
        phone: { type: string, nullable: true }
        address: { type: string, nullable: true }
        isActive: { type: boolean }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        pickingType: { type: string, nullable: true }
        pickingCategory: { type: string }
        agentId: { type: string, nullable: true }

    CustomerCreate:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        email: { type: string, format: email, maxLength: 254 }
        phone:
          type: string
          maxLength: 30
          pattern: "^[\\d+\\-\\s()]+$"
        address: { type: string, maxLength: 500 }
        isActive: { type: boolean }

    CustomerUpdate:
      type: object
      additionalProperties: false
      minProperties: 1
      description: |
        עדכון partial. שלחו `null` בשדה nullable כדי לאפס אותו (למשל
        `{ "email": null }`). לפחות שדה אחד חובה.
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        email: { type: string, format: email, maxLength: 254, nullable: true }
        phone:
          type: string
          maxLength: 30
          pattern: "^[\\d+\\-\\s()]+$"
          nullable: true
        address: { type: string, maxLength: 500, nullable: true }
        isActive: { type: boolean }

    WebhookSubscription:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventType" }
        isActive: { type: boolean }
        lastDeliveryAt: { type: string, format: date-time, nullable: true }
        lastDeliveryStatus:
          type: string
          nullable: true
          enum: [pending, success, failed, exhausted, null]
        createdByName: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    WebhookEventType:
      type: string
      description: סוגי האירועים שניתן להירשם אליהם
      enum:
        - shipment.created
        - shipment.status_changed
        - shipment.assigned
        - shipment.delivered
        - shipment.failed
        - visit.completed
        - cod.collected
        - customer.created

    WebhookDeliveryPayload:
      type: object
      description: |
        הגוף ש-Shipnest שולחת ב-POST ל-URL שלכם. אימות חתימה: HMAC-SHA256
        על המחרוזת `${X-Webhook-Timestamp}.${rawBody}` עם ה-secret של
        ה-subscription.
      required: [event, tenantId, occurredAt, data]
      properties:
        event: { $ref: "#/components/schemas/WebhookEventType" }
        tenantId: { type: string }
        occurredAt: { type: string, format: date-time }
        data:
          type: object
          description: |
            תוכן ספציפי לאירוע. דוגמאות:
            - shipment.created → `{ shipmentId, barcode, customerName, phone, destinationCity, destinationStreet, status }`
            - shipment.assigned → `{ shipmentId, barcode, visitId, driverId, driverName, previousDriverId, action }`
            - shipment.failed → `{ shipmentId, barcode, visitId, driverId, failedAt, reasonCode, reason }`
            - shipment.delivered → `{ shipmentId, barcode, visitId, driverId, deliveredAt }`
            - visit.completed → `{ shipmentId, barcode, visitId, driverId, deliveredAt }`
            - shipment.status_changed → `{ shipmentId, barcode, oldStatus?, newStatus, canceledAt?, reason? }`
            - customer.created → `{ customerId, name, email, phone, source }`
            - cod.collected → `{ codTrackingId, barcode, amount, collectionStatus }`
