packages feed

notion-client-0.1.0.0: src/Notion/V1/Webhooks.hs

-- | Notion Webhook types and utilities
--
-- This module provides types for handling incoming webhook events from Notion.
-- Webhook subscriptions are created via the Notion integration UI, not via API.
--
-- Usage:
--
-- @
-- import Notion.V1.Webhooks
-- import Data.Aeson (eitherDecode)
--
-- handleWebhook :: ByteString -> Text -> Text -> IO ()
-- handleWebhook body signature verificationToken = do
--   -- Verify the signature
--   case verifySignature verificationToken body signature of
--     False -> error "Invalid signature"
--     True -> do
--       -- Parse the event
--       case eitherDecode body of
--         Left err -> error err
--         Right event -> processEvent event
--
-- processEvent :: WebhookEvent -> IO ()
-- processEvent event = case event.type_ of
--   PageCreated -> putStrLn "Page created!"
--   CommentCreated -> putStrLn "Comment created!"
--   _ -> putStrLn "Other event"
-- @
module Notion.V1.Webhooks
  ( -- * Event types
    WebhookEvent (..),
    EventType (..),
    WebhookEntity (..),
    EntityType (..),
    Author (..),
    AccessibleBy (..),

    -- * Verification
    VerificationPayload (..),
    verifySignature,
    computeSignature,
  )
where

import Crypto.Hash.SHA256 qualified as SHA256
import Data.Aeson (object, (.:), (.:?), (.=))
import Data.Bits (xor, (.|.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Base16 qualified as Base16
import Data.Text.Encoding qualified as Text
import Notion.Prelude hiding (ByteString)
import Notion.V1.Common (UUID (..))

-- | Webhook event types supported by Notion
data EventType
  = -- | Page events
    PageCreated
  | PageDeleted
  | PageUndeleted
  | PagePropertiesUpdated
  | PageContentUpdated
  | PageMoved
  | PageLocked
  | PageUnlocked
  | -- | Database events (deprecated as of 2025-09-03)
    DatabaseCreated
  | DatabaseDeleted
  | DatabaseUndeleted
  | DatabaseContentUpdated
  | DatabaseSchemaUpdated
  | DatabaseMoved
  | -- | Data source events (new in 2025-09-03)
    DataSourceCreated
  | DataSourceDeleted
  | DataSourceUndeleted
  | DataSourceContentUpdated
  | DataSourceSchemaUpdated
  | DataSourceMoved
  | -- | Comment events
    CommentCreated
  | CommentUpdated
  | CommentDeleted
  | -- | Unknown event type (for forward compatibility)
    UnknownEvent Text
  deriving stock (Eq, Show, Generic)

instance FromJSON EventType where
  parseJSON = \case
    String "page.created" -> pure PageCreated
    String "page.deleted" -> pure PageDeleted
    String "page.undeleted" -> pure PageUndeleted
    String "page.properties_updated" -> pure PagePropertiesUpdated
    String "page.content_updated" -> pure PageContentUpdated
    String "page.moved" -> pure PageMoved
    String "page.locked" -> pure PageLocked
    String "page.unlocked" -> pure PageUnlocked
    String "database.created" -> pure DatabaseCreated
    String "database.deleted" -> pure DatabaseDeleted
    String "database.undeleted" -> pure DatabaseUndeleted
    String "database.content_updated" -> pure DatabaseContentUpdated
    String "database.schema_updated" -> pure DatabaseSchemaUpdated
    String "database.moved" -> pure DatabaseMoved
    String "data_source.created" -> pure DataSourceCreated
    String "data_source.deleted" -> pure DataSourceDeleted
    String "data_source.undeleted" -> pure DataSourceUndeleted
    String "data_source.content_updated" -> pure DataSourceContentUpdated
    String "data_source.schema_updated" -> pure DataSourceSchemaUpdated
    String "data_source.moved" -> pure DataSourceMoved
    String "comment.created" -> pure CommentCreated
    String "comment.updated" -> pure CommentUpdated
    String "comment.deleted" -> pure CommentDeleted
    String other -> pure $ UnknownEvent other
    _ -> fail "Expected string for EventType"

instance ToJSON EventType where
  toJSON = \case
    PageCreated -> String "page.created"
    PageDeleted -> String "page.deleted"
    PageUndeleted -> String "page.undeleted"
    PagePropertiesUpdated -> String "page.properties_updated"
    PageContentUpdated -> String "page.content_updated"
    PageMoved -> String "page.moved"
    PageLocked -> String "page.locked"
    PageUnlocked -> String "page.unlocked"
    DatabaseCreated -> String "database.created"
    DatabaseDeleted -> String "database.deleted"
    DatabaseUndeleted -> String "database.undeleted"
    DatabaseContentUpdated -> String "database.content_updated"
    DatabaseSchemaUpdated -> String "database.schema_updated"
    DatabaseMoved -> String "database.moved"
    DataSourceCreated -> String "data_source.created"
    DataSourceDeleted -> String "data_source.deleted"
    DataSourceUndeleted -> String "data_source.undeleted"
    DataSourceContentUpdated -> String "data_source.content_updated"
    DataSourceSchemaUpdated -> String "data_source.schema_updated"
    DataSourceMoved -> String "data_source.moved"
    CommentCreated -> String "comment.created"
    CommentUpdated -> String "comment.updated"
    CommentDeleted -> String "comment.deleted"
    UnknownEvent t -> String t

-- | Entity types in webhook events
data EntityType
  = PageEntity
  | DatabaseEntity
  | DataSourceEntity
  | CommentEntity
  | UnknownEntityType Text
  deriving stock (Eq, Show, Generic)

instance FromJSON EntityType where
  parseJSON = \case
    String "page" -> pure PageEntity
    String "database" -> pure DatabaseEntity
    String "data_source" -> pure DataSourceEntity
    String "comment" -> pure CommentEntity
    String other -> pure $ UnknownEntityType other
    _ -> fail "Expected string for EntityType"

instance ToJSON EntityType where
  toJSON = \case
    PageEntity -> String "page"
    DatabaseEntity -> String "database"
    DataSourceEntity -> String "data_source"
    CommentEntity -> String "comment"
    UnknownEntityType t -> String t

-- | Entity that triggered the webhook event
data WebhookEntity = WebhookEntity
  { id :: UUID,
    type_ :: EntityType
  }
  deriving stock (Eq, Show, Generic)

instance FromJSON WebhookEntity where
  parseJSON = \case
    Object o -> do
      id <- o .: "id"
      type_ <- o .: "type"
      pure WebhookEntity {..}
    _ -> fail "Expected object for WebhookEntity"

instance ToJSON WebhookEntity where
  toJSON WebhookEntity {..} =
    object
      [ "id" .= id,
        "type" .= type_
      ]

-- | Author who triggered the event (user or bot)
data Author = Author
  { id :: UUID,
    type_ :: Text
  }
  deriving stock (Eq, Show, Generic)

instance FromJSON Author where
  parseJSON = \case
    Object o -> do
      id <- o .: "id"
      type_ <- o .: "type"
      pure Author {..}
    _ -> fail "Expected object for Author"

instance ToJSON Author where
  toJSON Author {..} =
    object
      [ "id" .= id,
        "type" .= type_
      ]

-- | User or bot with access to the affected entity
data AccessibleBy = AccessibleBy
  { id :: UUID,
    type_ :: Text
  }
  deriving stock (Eq, Show, Generic)

instance FromJSON AccessibleBy where
  parseJSON = \case
    Object o -> do
      id <- o .: "id"
      type_ <- o .: "type"
      pure AccessibleBy {..}
    _ -> fail "Expected object for AccessibleBy"

instance ToJSON AccessibleBy where
  toJSON AccessibleBy {..} =
    object
      [ "id" .= id,
        "type" .= type_
      ]

-- | A webhook event sent by Notion to your endpoint
data WebhookEvent = WebhookEvent
  { -- | Unique identifier for this event
    id :: UUID,
    -- | When the event occurred (ISO 8601)
    timestamp :: POSIXTime,
    -- | Workspace where the event originated
    workspaceId :: UUID,
    -- | Associated webhook subscription
    subscriptionId :: UUID,
    -- | Integration that owns the subscription
    integrationId :: UUID,
    -- | Type of event
    type_ :: EventType,
    -- | Users/bots who triggered the action
    authors :: Vector Author,
    -- | Users/bots with access to the entity
    accessibleBy :: Vector AccessibleBy,
    -- | Delivery attempt number (1-8)
    attemptNumber :: Int,
    -- | Entity that triggered the event
    entity :: WebhookEntity,
    -- | Event-specific data (varies by event type)
    data_ :: Maybe Value
  }
  deriving stock (Show, Generic)

instance FromJSON WebhookEvent where
  parseJSON = \case
    Object o -> do
      id <- o .: "id"
      timestampText <- o .: "timestamp"
      timestamp <- parseISO8601 timestampText
      workspaceId <- o .: "workspace_id"
      subscriptionId <- o .: "subscription_id"
      integrationId <- o .: "integration_id"
      type_ <- o .: "type"
      authors <- o .: "authors"
      accessibleBy <- o .: "accessible_by"
      attemptNumber <- o .: "attempt_number"
      entity <- o .: "entity"
      data_ <- o .:? "data"
      pure WebhookEvent {..}
    _ -> fail "Expected object for WebhookEvent"

instance ToJSON WebhookEvent where
  toJSON = genericToJSON aesonOptions

-- | Verification payload sent by Notion when setting up a webhook
-- Your endpoint should receive this and confirm the token in the Notion UI
data VerificationPayload = VerificationPayload
  { verificationToken :: Text
  }
  deriving stock (Eq, Show, Generic)

instance FromJSON VerificationPayload where
  parseJSON = genericParseJSON aesonOptions

instance ToJSON VerificationPayload where
  toJSON = genericToJSON aesonOptions

-- | Compute HMAC-SHA256 signature for webhook payload validation
--
-- The signature is computed as: sha256=HMAC-SHA256(verification_token, body)
computeSignature ::
  -- | Verification token (used as HMAC key)
  Text ->
  -- | Request body (minified JSON)
  ByteString ->
  -- | Computed signature in "sha256=..." format
  Text
computeSignature verificationToken body =
  "sha256=" <> Text.decodeUtf8 (Base16.encode hmacDigest)
  where
    key = Text.encodeUtf8 verificationToken
    hmacDigest = SHA256.hmac key body

-- | Verify webhook signature from X-Notion-Signature header
--
-- Uses constant-time comparison to prevent timing attacks.
--
-- Example:
--
-- @
-- isValid = verifySignature myToken requestBody headerSignature
-- @
verifySignature ::
  -- | Verification token (from webhook setup)
  Text ->
  -- | Request body (minified JSON as received)
  ByteString ->
  -- | Signature from X-Notion-Signature header
  Text ->
  -- | True if signature is valid
  Bool
verifySignature verificationToken body headerSignature =
  constantTimeCompare expected actual
  where
    expected = Text.encodeUtf8 $ computeSignature verificationToken body
    actual = Text.encodeUtf8 headerSignature

-- | Constant-time comparison to prevent timing attacks
constantTimeCompare :: ByteString -> ByteString -> Bool
constantTimeCompare a b
  | BS.length a /= BS.length b = False
  | otherwise = 0 == BS.foldl' (\acc w -> acc .|. w) 0 (BS.packZipWith xor a b)