openapi: "3.0.3"
info:
  title: Coolock Village Civic Platform API
  description: |
    Open311 GeoReport v2 compatible API for civic issue reporting in Coolock Village, Dublin 5.

    Follows the [Open311 specification](http://wiki.open311.org/GeoReport_v2/) and
    [Smart Dublin data standards](https://data.smartdublin.ie).

    ## Authentication
    - Public endpoints: No auth required (rate-limited)
    - Reporter endpoints: Magic Link / Passkey session (`coolock_session` cookie)
    - Admin endpoints: API key in `X-API-Key` header, or Magic Link / Passkey session
    - Admin panel supports all three methods: API key, Magic Link, and Passkey

    ## Rate Limits
    All POST endpoints enforce 5 requests per 15 minutes per IP.
    Rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`) included in all responses.

    ## CSRF Protection
    All POST endpoints validate `Origin`/`Referer` headers against `coolockvillage.ie`.

    ## Input Sanitization
    All text inputs are HTML-stripped and length-capped.
  version: "2.0.0"
  contact:
    name: Coolock Village Regeneration Project
    email: info@coolockvillage.ie
    url: https://coolockvillage.ie
  license:
    name: Civic Source License v1.0
    url: https://github.com/Coolock-Village/coolock-village-forge/blob/main/LICENSE

servers:
  - url: https://coolockvillage.ie
    description: Production (Cloudflare Pages)
  - url: http://127.0.0.1:4321
    description: Local development (Astro dev server)

security: []

tags:
  - name: Open311
    description: Open311 GeoReport v2 endpoints
  - name: Reports
    description: Issue report management
  - name: Volunteers
    description: Volunteer signup (feature-flagged)
  - name: Ideas
    description: Idea submission (feature-flagged)
  - name: Newsletter
    description: Newsletter subscription (feature-flagged)
  - name: Comments
    description: Unified comment threads for any content type
  - name: Businesses
    description: Canonical civic business directory (read-only, public)
  - name: CivicDashboard
    description: Live civic data dashboard — planning, transport, environment (feature-flagged)

paths:
  /api/v1/civic-dashboard:
    get:
      tags: [CivicDashboard]
      summary: Fetch aggregated civic data from all providers
      operationId: getCivicDashboard
      description: |
        Returns a unified JSON response of live data from enabled civic
        data providers (planning, transport, environment).
        Feature-flagged: returns 404 when civicDashboard flag is disabled.
        Cached for 5 minutes at the edge.
      responses:
        "200":
          description: Civic data widgets
          content:
            application/json:
              schema:
                type: object
                properties:
                  widgets:
                    type: array
                    items:
                      type: object
                  meta:
                    type: object
                    properties:
                      timestamp:
                        type: string
                        format: date-time
                      providerCount:
                        type: integer
        "404":
          description: Feature not available
        "500":
          description: Provider fetch failed

  /api/civic/planning:
    get:
      tags: [CivicDashboard]
      summary: Proxy for Agile Applications planning search
      operationId: getAgilePlanning
      description: |
        Server-side proxy that attempts to bypass client-side bot protection locks
        on the Agile Applications portal when LGMA data is stale.
      responses:
        "200":
          description: Scraped and formatted planning applications
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                  items:
                    type: array
                    items:
                      type: object
                  lastUpdated:
                    type: string
                  attribution:
                    type: string
                  error:
                    type: string
        "500":
          description: Failed to proxy Agile Applications

  /api/posts/{slug}/react:
    get:
      tags: [Engagement]
      summary: Get reaction count for a post
      operationId: getPostReactions
      description: |
        Returns the current reaction count for a post and whether
        the requesting user has already reacted (via SHA-256 hash dedup).
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Reaction data
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      slug:
                        type: string
                      count:
                        type: integer
                      userReacted:
                        type: boolean
    post:
      tags: [Engagement]
      summary: Add a reaction to a post
      operationId: reactToPost
      description: |
        Adds a reaction to a post. Deduplicates via SHA-256(IP+UA+slug).
        Rate limited to 20 requests per 15 minutes. Region-restricted.
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Reaction added
        "403":
          description: Forbidden (CSRF, region, or disabled)
        "409":
          description: Already reacted
        "429":
          description: Rate limited

  /api/v1/businesses:
    get:
      tags: [Businesses]
      summary: List all businesses in the canonical dataset
      operationId: listBusinesses
      description: |
        Returns the canonical business directory as a schema.org ItemList.
        Supports filtering by category, schemaType, and free-text search.
      parameters:
        - name: category
          in: query
          schema:
            type: string
          description: Filter by display category (e.g. "Food & Drink")
        - name: schemaType
          in: query
          schema:
            type: string
          description: Filter by schema.org type (e.g. "Restaurant")
        - name: q
          in: query
          schema:
            type: string
          description: Free-text search across name and description
      responses:
        "200":
          description: Business directory listing
          content:
            application/json:
              schema:
                type: object
                properties:
                  "@context":
                    type: string
                    example: "https://schema.org"
                  "@type":
                    type: string
                    example: ItemList
                  numberOfItems:
                    type: integer
                  itemListElement:
                    type: array
                    items:
                      type: object

  /api/v1/businesses/{id}:
    get:
      tags: [Businesses]
      summary: Get a single business by canonical ID
      operationId: getBusinessById
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The business canonicalId (e.g. "spar-malahide-road")
      responses:
        "200":
          description: Full schema.org JSON-LD for a single business
          content:
            application/json:
              schema:
                type: object
        "404":
          description: Business not found

  /api/v1/businesses/export-csv:
    get:
      tags: [Businesses]
      summary: Export business dataset as CSV
      operationId: exportBusinessesCsv
      description: |
        Returns a flat CSV file suitable for OSM bulk imports and map vendor
        POI standards. Admin-only by default — requires X-Admin-Token header.
        Set business_directory.csv_export.public to true in capabilities.yaml
        to open publicly. Rate limited to 1 request/day per IP.
      security:
        - apiKey: []
      responses:
        "200":
          description: CSV file download
          content:
            text/csv:
              schema:
                type: string
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/diff:
    get:
      tags: [Businesses]
      summary: Get dataset changes since a given date
      operationId: getDatasetDiff
      description: |
        Returns business records that changed since the specified date.
        Designed for map correction ingestion and downstream sync.
      parameters:
        - name: since
          in: query
          required: true
          schema:
            type: string
            format: date
          description: ISO 8601 date (e.g. "2026-02-01")
      responses:
        "200":
          description: Diff result
          content:
            application/json:
              schema:
                type: object
                properties:
                  since:
                    type: string
                  hasChanges:
                    type: boolean
                  summary:
                    type: object
                    properties:
                      added:
                        type: integer
                      modified:
                        type: integer
                      removed:
                        type: integer
                  changes:
                    type: array
                    items:
                      type: object
        "400":
          description: Missing or invalid since parameter

  /v2/discovery.json:
    get:
      tags: [Open311]
      summary: API Discovery
      operationId: getDiscovery
      responses:
        "200":
          description: API discovery document
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Discovery"

  /v2/services.json:
    get:
      tags: [Open311]
      summary: List available services
      operationId: getServices
      responses:
        "200":
          description: List of services
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Service"

  /v2/requests.json:
    get:
      tags: [Open311]
      summary: List service requests
      operationId: getRequests
      parameters:
        - name: service_code
          in: query
          schema:
            type: string
        - name: status
          in: query
          schema:
            type: string
            enum: [open, closed]
        - name: start_date
          in: query
          schema:
            type: string
            format: date-time
        - name: end_date
          in: query
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: List of service requests
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ServiceRequest"
    post:
      tags: [Open311]
      summary: Create a service request
      operationId: createRequest
      security:
        - apiKey: []
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateRequest"
      responses:
        "201":
          description: Request created
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/CreateResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v2/requests/{service_request_id}:
    get:
      tags: [Open311]
      summary: Get a single service request
      operationId: getRequest
      parameters:
        - name: service_request_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Service request details
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ServiceRequest"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/reports:
    get:
      tags: [Reports]
      summary: List publicly visible issue reports
      operationId: listReports
      parameters:
        - name: status
          in: query
          schema:
            type: string
        - name: category
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
        - name: min_lat
          in: query
          schema:
            type: number
        - name: max_lat
          in: query
          schema:
            type: number
        - name: min_lng
          in: query
          schema:
            type: number
        - name: max_lng
          in: query
          schema:
            type: number
      responses:
        "200":
          description: List of reports
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Report"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [Reports]
      summary: Submit an issue report
      operationId: createReport
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportCreate"
      responses:
        "201":
          description: Report created
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/reports/{id}:
    get:
      tags: [Reports]
      summary: Get a single issue report by ID
      operationId: getReport
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: A single report
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: "#/components/schemas/Report"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    put:
      tags: [Reports]
      summary: Edit a report (admin)
      operationId: editReport
      description: |
        Admin-only endpoint. Requires `X-API-Key` header.
        Allows editing title, description, and category.
      security:
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  maxLength: 200
                description:
                  type: string
                  maxLength: 5000
                category:
                  type: string
      responses:
        "200":
          description: Report updated
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Reports]
      summary: Delete a report (admin)
      operationId: deleteReport
      description: |
        Admin-only endpoint. Requires `X-API-Key` header.
        Permanently deletes the report. This action cannot be undone.
      security:
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Report deleted
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/reports/{id}/status:
    patch:
      tags: [Reports]
      summary: Update report status (admin)
      operationId: updateReportStatus
      description: |
        Admin-only. Transitions a report through status states.
      security:
        - cookieAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_status]
              properties:
                new_status:
                  type: string
                  enum:
                    [
                      pending_moderation,
                      open,
                      acknowledged,
                      in_progress,
                      resolved,
                      closed,
                    ]
                note:
                  type: string
                  maxLength: 2000
      responses:
        "200":
          description: Status updated
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/reports/{id}/visibility:
    patch:
      tags: [Reports]
      summary: Toggle report visibility (admin)
      operationId: toggleReportVisibility
      description: |
        Admin-only endpoint. Requires `X-API-Key` header.
        Hidden reports are excluded from public listings but remain visible in the admin panel.
      security:
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [hidden]
              properties:
                hidden:
                  type: boolean
      responses:
        "200":
          description: Visibility updated
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/reports/{id}/comments:
    get:
      tags: [Reports]
      summary: List approved comments on a report
      operationId: listReportComments
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: List of comments
    post:
      tags: [Reports]
      summary: Add a public comment to a report
      operationId: createReportComment
      description: |
        Comments are subjected to geo-restriction (CF-IPCountry must be IE/GB).
        Comments require moderation before public display.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [author_name, content]
              properties:
                author_name:
                  type: string
                  maxLength: 100
                content:
                  type: string
                  maxLength: 2000
      responses:
        "201":
          description: Comment received and pending moderation
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          description: Geo-restricted or forbidden
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/volunteers:
    post:
      tags: [Volunteers]
      summary: Sign up as a volunteer
      operationId: createVolunteer
      description: |
        Rate limited: 5 requests per 15 minutes per IP.
        Origin validation required (CSRF protection).
        All text inputs sanitized (HTML stripped, length capped).
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VolunteerCreate"
      responses:
        "201":
          description: Signup received
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/ideas:
    get:
      tags: [Ideas]
      summary: List approved community ideas
      operationId: listIdeas
      description: |
        Returns ideas with status `approved` or `featured` only.
        Strips `author_email` (PII) from public response.
        Rate limited: 30 requests per minute per IP.
      parameters:
        - name: category
          in: query
          schema:
            type: string
          description: Filter by category (safety, beautification, community, etc.)
        - name: sort
          in: query
          schema:
            type: string
            enum: [newest, popular]
            default: newest
          description: Sort order
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: Approved ideas
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/IdeaPublic"
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      limit:
                        type: integer
                      offset:
                        type: integer
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [Ideas]
      summary: Submit a neighborhood idea
      operationId: createIdea
      description: |
        Ideas require admin review before public display.
        Email optional (anonymous submissions allowed).
        Rate limited: 5 requests per 15 minutes per IP.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IdeaCreate"
      responses:
        "201":
          description: Idea received (sent to admin for review)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/ideas/{id}/vote:
    post:
      tags: [Ideas]
      summary: Vote for a community idea
      operationId: voteForIdea
      description: |
        Privacy-preserving deduplication via SHA-256(IP + UserAgent).
        Only allows voting on approved or featured ideas.
        Rate limited: 10 votes per 15 minutes per IP.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Idea ID
      responses:
        "200":
          description: Vote recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      support_count:
                        type: integer
        "404":
          description: Idea not found
        "409":
          description: Already voted
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/comments:
    get:
      tags: [Comments]
      summary: List approved comments for a content item
      operationId: listComments
      parameters:
        - name: target_type
          in: query
          required: true
          schema:
            type: string
            enum: [report, idea, update]
        - name: target_id
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Approved comments
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        author_name:
                          type: string
                        content:
                          type: string
                        created_at:
                          type: string
                          format: date-time
                  closed:
                    type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
    post:
      tags: [Comments]
      summary: Submit a comment (requires authentication)
      operationId: createComment
      description: |
        Requires authenticated session. Author name auto-populated from profile.
        Rate limited: 5 requests per 15 minutes per IP.
        Comments require moderation before public display.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [target_type, target_id, content]
              properties:
                target_type:
                  type: string
                  enum: [report, idea, update]
                target_id:
                  type: string
                content:
                  type: string
                  minLength: 10
                  maxLength: 2000
      responses:
        "201":
          description: Comment submitted for moderation
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Authentication required
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/comments/reactions:
    put:
      tags: [Comments]
      summary: Toggle a reaction on a comment
      operationId: toggleReaction
      description: |
        Toggles an emoji reaction on an approved comment.
        If the reaction already exists for this user, it is removed.
        Requires authenticated session. Rate limited.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [comment_id, reaction_type]
              properties:
                comment_id:
                  type: string
                reaction_type:
                  type: string
                  enum: [thumbsUp, heart, celebrate, eyes, sad]
      responses:
        "200":
          description: Reaction removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  action:
                    type: string
                    enum: [removed]
        "201":
          description: Reaction added
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  action:
                    type: string
                    enum: [added]
        "401":
          description: Authentication required
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/newsletter:
    post:
      tags: [Newsletter]
      summary: Subscribe to newsletter
      operationId: subscribeNewsletter
      description: |
        Rate limited: 5 requests per 15 minutes per IP.
        Email validated (format and length).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 320
      responses:
        "201":
          description: Subscribed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/notifications/preferences:
    get:
      tags: [Notifications]
      summary: Get user notification preferences
      operationId: getNotificationPreferences
      description: |
        Returns per-type notification preferences for the authenticated user.
        Categories: transactional (activity-triggered) and promotional (opt-in).
      security:
        - cookieAuth: []
      responses:
        "200":
          description: Preferences retrieved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      preferences:
                        type: array
                        items:
                          type: object
                          properties:
                            type:
                              type: string
                            category:
                              type: string
                              enum: [transactional, promotional]
                            enabled:
                              type: boolean
                      unsubscribeAll:
                        type: boolean
        "401":
          description: Authentication required
    put:
      tags: [Notifications]
      summary: Update notification preferences
      operationId: updateNotificationPreferences
      description: |
        Update per-type notification toggles. Rate limited.
      security:
        - cookieAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email_report_updates:
                  type: boolean
                email_community_digest:
                  type: boolean
                email_comment_replies:
                  type: boolean
                email_idea_updates:
                  type: boolean
                email_volunteer_updates:
                  type: boolean
                email_unsubscribe_all:
                  type: boolean
      responses:
        "200":
          description: Preferences updated
        "401":
          description: Authentication required
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/notifications/unsubscribe:
    get:
      tags: [Notifications]
      summary: One-click email unsubscribe (RFC 8058)
      operationId: unsubscribeEmail
      description: |
        Validates HMAC-signed token from email link and updates user preference.
        No authentication required (token is self-authenticating).
      parameters:
        - name: token
          in: query
          required: true
          schema:
            type: string
          description: HMAC-signed unsubscribe token
      responses:
        "200":
          description: Successfully unsubscribed (HTML page)
          content:
            text/html:
              schema:
                type: string
        "400":
          description: Invalid or expired token
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [Notifications]
      summary: One-click unsubscribe via POST (RFC 8058 List-Unsubscribe-Post)
      operationId: unsubscribeEmailPost
      description: Same as GET, for RFC 8058 compliance.
      parameters:
        - name: token
          in: query
          required: true
          schema:
            type: string
      requestBody:
        description: RFC 8058 List-Unsubscribe-Post body
        content:
          "*/*":
            schema:
              type: object
              properties:
                List-Unsubscribe:
                  type: string
      responses:
        "200":
          description: Successfully unsubscribed

  /api/auth/magic-link:
    post:
      tags: [Reports]
      summary: Request magic link email
      operationId: requestMagicLink
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 320
      responses:
        "200":
          description: Link sent

  /api/auth/logout:
    post:
      tags: [Reports]
      summary: Logout
      operationId: logout
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
      responses:
        "200":
          description: Logged out

  /api/auth/passkey-login:
    post:
      tags: [Reports]
      summary: Passkey login verification
      operationId: verifyPasskeyLogin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [session_id, assertion]
              properties:
                session_id:
                  type: string
                assertion:
                  type: object
      responses:
        "200":
          description: Logged in

  /api/auth/passkey-register-start:
    post:
      tags: [Reports]
      summary: Start passkey registration
      operationId: startPasskeyRegister
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
      responses:
        "200":
          description: Registration started

  /api/auth/passkey-register-finish:
    post:
      tags: [Reports]
      summary: Finish passkey registration
      operationId: finishPasskeyRegister
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [session_id, attestation]
              properties:
                session_id:
                  type: string
                attestation:
                  type: object
      responses:
        "200":
          description: Registration finished

  /api/users/sessions:
    get:
      tags: [Account]
      summary: List own sessions
      operationId: listOwnSessions
      security:
        - cookieAuth: []
      responses:
        "200":
          description: Session list with current marker
        "401":
          description: Unauthorized
    delete:
      tags: [Account]
      summary: Revoke own sessions
      operationId: revokeOwnSessions
      security:
        - cookieAuth: []
      parameters:
        - name: session_id_prefix
          in: query
          required: false
          schema:
            type: string
        - name: all
          in: query
          required: false
          schema:
            type: string
            enum: ["true"]
      responses:
        "200":
          description: Session(s) revoked
        "400":
          description: Invalid request or current session
        "401":
          description: Unauthorized
        "404":
          description: Session not found

  /api/users/push:
    post:
      tags: [Notifications]
      summary: Subscribe to push notifications
      operationId: subscribePush
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpoint, keys]
              properties:
                endpoint:
                  type: string
                keys:
                  type: object
      responses:
        "201":
          description: Subscribed
    delete:
      tags: [Notifications]
      summary: Unsubscribe from push notifications
      operationId: unsubscribePush
      parameters:
        - name: endpoint
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Unsubscribed

  /api/users/data-export:
    get:
      tags: [Users]
      summary: GDPR data export — download all user data as JSON
      operationId: exportUserData
      security:
        - cookieAuth: []
      responses:
        "200":
          description: JSON file download with all user data
        "401":
          description: Unauthorized
        "503":
          description: Database not configured

  /api/users/delete-account:
    delete:
      tags: [Users]
      summary: Permanently delete user account and anonymize reports
      operationId: deleteUserAccount
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm]
              properties:
                confirm:
                  type: string
                  enum: [DELETE_MY_ACCOUNT]
      responses:
        "200":
          description: Account deleted
        "400":
          description: Missing confirmation
        "401":
          description: Unauthorized

  /api/users/passkey-delete:
    delete:
      tags: [Users]
      summary: Remove a registered passkey (WebAuthn credential)
      operationId: deletePasskey
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [credential_id]
              properties:
                credential_id:
                  type: string
                  description: The WebAuthn credential ID to remove
      responses:
        "200":
          description: Passkey removed
        "401":
          description: Unauthorized
        "404":
          description: Passkey not found or not owned by user

  /api/users/profile:
    get:
      tags: [Users]
      summary: Retrieve current user's profile (email, display name)
      operationId: getUserProfile
      description: |
        Used by ReportForm to auto-populate name and email when signed in.
        Returns 401 if not authenticated (expected — form silently falls back).
      security:
        - cookieAuth: []
      responses:
        "200":
          description: User profile data
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      email:
                        type: string
                        format: email
                      display_name:
                        type: string
                        nullable: true
        "401":
          description: Not authenticated
        "404":
          description: User not found
    patch:
      tags: [Users]
      summary: Update user display name and email preferences
      operationId: updateUserProfile
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name:
                  type: string
                  maxLength: 100
                email_report_updates:
                  type: boolean
                email_community_digest:
                  type: boolean
                theme_preference:
                  type: string
                  enum: [system, dark, light]
      responses:
        "200":
          description: Profile updated
        "400":
          description: No fields to update
        "401":
          description: Unauthorized

  /api/admin/reports:
    get:
      tags: [Admin]
      summary: List all reports (admin-only, includes hidden/pending)
      operationId: adminListReports
      security:
        - apiKey: []
        - cookieAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 100
        - name: status
          in: query
          schema:
            type: string
      responses:
        "200":
          description: All reports
        "401":
          description: Unauthorized
        "403":
          description: Admin access required

  /api/admin/comments:
    get:
      tags: [Admin]
      summary: List all comments for moderation (pending first)
      operationId: adminListComments
      security:
        - cookieAuth: []
        - apiKey: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 100
      responses:
        "200":
          description: Comments list
        "403":
          $ref: "#/components/responses/Forbidden"

  /api/admin/comments/{id}:
    put:
      tags: [Admin]
      summary: Approve or reject a comment
      operationId: adminModerateComment
      security:
        - cookieAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                approved:
                  type: boolean
      responses:
        "200":
          description: Comment moderated
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Admin]
      summary: Permanently delete a comment
      operationId: adminDeleteComment
      security:
        - cookieAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Comment deleted
        "404":
          $ref: "#/components/responses/NotFound"

  /api/admin/ideas:
    get:
      tags: [Admin]
      summary: List all community ideas (admin-only, all statuses)
      operationId: adminListIdeas
      security:
        - apiKey: []
        - cookieAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 100
      responses:
        "200":
          description: All ideas
        "401":
          description: Unauthorized
        "403":
          description: Admin access required

  /api/admin/ideas/{id}:
    delete:
      tags: [Admin]
      summary: Permanently delete an idea
      operationId: adminDeleteIdea
      security:
        - apiKey: []
        - cookieAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Idea deleted
        "403":
          description: Admin access required

  /api/admin/ideas/{id}/status:
    put:
      tags: [Admin]
      summary: Update idea status (approve, feature, implement)
      operationId: adminUpdateIdeaStatus
      security:
        - apiKey: []
        - cookieAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [pending, approved, featured, implemented]
      responses:
        "200":
          description: Status updated
        "400":
          description: Invalid status
        "403":
          description: Admin access required

  /api/admin/users:
    get:
      tags: [Admin]
      summary: List all users (admin-only) with pagination, search, and filters
      operationId: adminListUsers
      security:
        - apiKey: []
        - cookieAuth: []
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 25
            maximum: 100
        - name: search
          in: query
          schema:
            type: string
        - name: role
          in: query
          schema:
            type: string
            enum: [user, admin]
        - name: sort
          in: query
          schema:
            type: string
            enum: [newest, oldest, last_active, email]
            default: newest
        - name: notification_status
          in: query
          description: Filter users by notification preference status
          schema:
            type: string
            enum: [subscribed, unsubscribed, push_enabled]
      responses:
        "200":
          description: Paginated users list
        "401":
          description: Unauthorized
        "403":
          description: Admin access required

    post:
      tags: [Admin]
      summary: Create a new user manually (admin-only)
      operationId: adminCreateUser
      description: |
        Creates a user record with notification defaults. Does not create a session —
        the user must authenticate via magic link or OAuth on first login.
        Optionally sends a welcome email with privacy policy and terms links.
        Dispatches integration webhook if configured (Zendesk/Intercom/HubSpot/custom).
      security:
        - apiKey: []
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  description: User's email address (must be unique)
                display_name:
                  type: string
                  maxLength: 100
                  description: Optional display name
                role:
                  type: string
                  enum: [user, admin]
                  default: user
                send_welcome_email:
                  type: boolean
                  default: true
                  description: Send a welcome email with privacy policy and terms links
      responses:
        "201":
          description: User created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      email:
                        type: string
                      role:
                        type: string
                      welcome_email_sent:
                        type: boolean
        "400":
          description: Invalid input (email format, display name, role)
        "401":
          description: Unauthorized
        "403":
          description: Admin access required or CSRF validation failed
        "409":
          description: User with this email already exists
        "413":
          description: Request body too large
        "429":
          description: Rate limit exceeded (5/min)

  /api/admin/users/{id}:
    get:
      tags: [Admin]
      summary: Get detailed user profile and activity summary
      operationId: adminGetUserDetail
      security:
        - apiKey: []
        - cookieAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: User detail object
        "401":
          description: Unauthorized
        "403":
          description: Admin access required
        "404":
          description: User not found
    patch:
      tags: [Admin]
      summary: Update user role (user/admin)
      operationId: adminUpdateUserRole
      security:
        - apiKey: []
        - cookieAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role:
                  type: string
                  enum: [user, admin]
      responses:
        "200":
          description: Role updated
        "400":
          description: Invalid request or preventing self-demotion
        "401":
          description: Unauthorized
        "403":
          description: Admin access required
        "404":
          description: User not found
    delete:
      tags: [Admin]
      summary: GDPR hard purge — cascade delete all user data
      operationId: adminPurgeUser
      security:
        - apiKey: []
        - cookieAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: User and data purged
        "403":
          description: Admin access required

  /api/admin/users/{id}/sessions:
    get:
      tags: [Admin]
      summary: List all sessions for a user
      operationId: adminListUserSessions
      security:
        - apiKey: []
        - cookieAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Session list with active/expired status
        "401":
          description: Unauthorized
        "403":
          description: Admin access required
        "404":
          description: User not found
    delete:
      tags: [Admin]
      summary: Revoke user sessions (individual or all)
      operationId: adminRevokeUserSessions
      security:
        - apiKey: []
        - cookieAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: session_id
          in: query
          required: false
          description: Specific session ID to revoke
          schema:
            type: string
        - name: all
          in: query
          required: false
          description: Set to "true" to revoke all sessions
          schema:
            type: string
            enum: ["true"]
      responses:
        "200":
          description: Session(s) revoked
        "400":
          description: Missing parameters or self-revocation attempt
        "401":
          description: Unauthorized
        "403":
          description: Admin access required
        "404":
          description: Session not found

  /api/admin/api-keys:
    get:
      tags: [Admin]
      summary: List API keys for current admin
      operationId: adminListAPIKeys
      security:
        - apiKey: []
        - cookieAuth: []
      responses:
        "200":
          description: List of API keys
        "403":
          description: Admin access required
    post:
      tags: [Admin]
      summary: Generate a new API key
      operationId: adminCreateAPIKey
      security:
        - apiKey: []
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 50
      responses:
        "201":
          description: Key generated (raw key returned once)
        "400":
          description: Name required
        "403":
          description: Admin access required

  /api/admin/api-keys/{id}:
    delete:
      tags: [Admin]
      summary: Revoke an API key
      operationId: adminDeleteAPIKey
      security:
        - apiKey: []
        - cookieAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Key revoked
        "403":
          description: Admin access required

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
    cookieAuth:
      type: apiKey
      in: cookie
      name: coolock_session
      description: Session cookie set after Magic Link or Passkey authentication
    oidc:
      type: openIdConnect
      openIdConnectUrl: https://auth.mygov.ie/.well-known/openid-configuration
      description: "Future: mygov.ie OIDC (not currently active)"

  schemas:
    Service:
      type: object
      properties:
        service_code:
          type: string
        service_name:
          type: string
        description:
          type: string
        metadata:
          type: boolean
        type:
          type: string
          enum: [realtime, batch, blackbox]
        keywords:
          type: string
        group:
          type: string

    ServiceRequest:
      type: object
      properties:
        service_request_id:
          type: string
        status:
          type: string
          enum: [open, closed]
        service_code:
          type: string
        description:
          type: string
        requested_datetime:
          type: string
          format: date-time
        updated_datetime:
          type: string
          format: date-time
        lat:
          type: number
        long:
          type: number
        address:
          type: string

    CreateRequest:
      type: object
      required: [service_code]
      properties:
        service_code:
          type: string
        lat:
          type: number
        long:
          type: number
        description:
          type: string
        email:
          type: string
          format: email
        first_name:
          type: string
        last_name:
          type: string

    CreateResponse:
      type: object
      properties:
        service_request_id:
          type: string
        token:
          type: string

    Discovery:
      type: object
      properties:
        changeset:
          type: string
        contact:
          type: string
        endpoints:
          type: array
          items:
            type: object
            properties:
              specification:
                type: string
              url:
                type: string
              type:
                type: string
              formats:
                type: array
                items:
                  type: string

    ReportCreate:
      type: object
      required: [title, description, category, location]
      properties:
        title:
          type: string
          maxLength: 200
        description:
          type: string
          maxLength: 5000
        category:
          type: string
        location:
          type: object
          properties:
            latitude:
              type: number
            longitude:
              type: number

    Report:
      type: object
      required:
        [
          id,
          title,
          description,
          category,
          status,
          location,
          created_at,
          updated_at,
        ]
      properties:
        id:
          type: string
        title:
          type: string
        description:
          type: string
        category:
          type: string
        subcategory:
          type: string
        status:
          type: string
          enum:
            [
              open,
              pending_moderation,
              acknowledged,
              in_progress,
              resolved,
              closed,
            ]
        location:
          type: object
          properties:
            latitude:
              type: number
            longitude:
              type: number
        address_text:
          type: string
        reporter_name:
          type: string
        dispatch_status:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    VolunteerCreate:
      type: object
      required: [name, email, interests]
      properties:
        name:
          type: string
          maxLength: 200
        email:
          type: string
          format: email
          maxLength: 320
        phone:
          type: string
          maxLength: 30
        interests:
          type: array
          items:
            type: string
            maxLength: 50
        availability:
          type: string
          enum: [weekdays, weekends, both]

    SuccessResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object

    IdeaCreate:
      type: object
      required: [title, description, category]
      properties:
        title:
          type: string
          maxLength: 200
        description:
          type: string
          maxLength: 5000
        category:
          type: string
        author_name:
          type: string
        author_email:
          type: string
          format: email

    IdeaPublic:
      type: object
      description: Public idea (PII stripped — no author_email)
      properties:
        id:
          type: string
        title:
          type: string
        description:
          type: string
        category:
          type: string
        author_name:
          type: string
        support_count:
          type: integer
        status:
          type: string
          enum: [approved, featured]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
                example: false
              error:
                type: string
    Forbidden:
      description: CSRF origin validation failed
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
                example: false
              error:
                type: string
                example: Forbidden
    NotFound:
      description: Resource not found
    RateLimited:
      description: Rate limit exceeded (5 requests per 15 minutes per IP)
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until rate limit resets
        X-RateLimit-Limit:
          schema:
            type: integer
          description: Maximum requests per window
        X-RateLimit-Remaining:
          schema:
            type: integer
          description: Requests remaining in current window
        X-RateLimit-Reset:
          schema:
            type: integer
          description: Unix timestamp when window resets
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
                example: false
              error:
                type: string
                example: Too many requests. Please try again later.
