packages feed

shomei-core-0.2.0.0: src/Shomei/Audit/Reader/Store.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE TypeFamilies #-}

-- | The audit-event *reader* port: the read counterpart to
-- 'Shomei.Audit.Publisher.Store'. It exposes filtered, keyset-paginated reads over the
-- append-only @shomei_auth_events@ table. The PostgreSQL interpreter lives in
-- @Shomei.Audit.Reader.Postgres@.
--
-- A 'StoredAuthEvent' carries the raw @storedPayload :: Value@ rather than a reconstructed
-- 'Shomei.Audit.Event.Domain.AuthEvent'; reconstruction is the caller's choice via
-- 'Shomei.Audit.Event.Codec.reconstructAuthEvent'. This keeps the storage read decoupled from
-- the JSON shape: an unrecognized future @event_type@ still lists (with its raw payload)
-- instead of breaking the whole query.
module Shomei.Audit.Reader.Store
  ( AuthEventReader (..),
    AuditEventQuery (..),
    AuditCursor (..),
    StoredAuthEvent (..),
    emptyAuditQuery,
    maxAuditLimit,
    clampLimit,
    queryAuthEvents,
    countAuthEvents,
  )
where

import Data.Aeson (Value)
import Data.UUID (UUID)
import Effectful (Dispatch (..), DispatchOf, Eff, Effect, (:>))
import Effectful.Dispatch.Dynamic (send)
import Shomei.Prelude

-- | A keyset-pagination cursor: the @(created_at, event_id)@ of the last row seen.
data AuditCursor = AuditCursor
  { cursorCreatedAt :: !UTCTime,
    cursorEventId :: !UUID
  }
  deriving stock (Eq, Show)

-- | Filters for an audit-event query. An empty 'queryEventTypes' means "all types".
data AuditEventQuery = AuditEventQuery
  { queryUserId :: !(Maybe UUID),
    querySessionId :: !(Maybe UUID),
    queryEventTypes :: ![Text],
    -- | inclusive lower bound on created_at
    querySince :: !(Maybe UTCTime),
    -- | exclusive upper bound on created_at
    queryUntil :: !(Maybe UTCTime),
    -- | clamp with 'clampLimit' before use
    queryLimit :: !Int,
    queryBefore :: !(Maybe AuditCursor)
  }
  deriving stock (Eq, Show)

-- | One row of the audit trail: the envelope columns plus the raw event payload.
data StoredAuthEvent = StoredAuthEvent
  { storedEventId :: !UUID,
    storedEventType :: !Text,
    storedUserId :: !(Maybe UUID),
    storedSessionId :: !(Maybe UUID),
    storedCreatedAt :: !UTCTime,
    storedPayload :: !Value
  }
  deriving stock (Eq, Show)

emptyAuditQuery :: AuditEventQuery
emptyAuditQuery = AuditEventQuery Nothing Nothing [] Nothing Nothing 50 Nothing

maxAuditLimit :: Int
maxAuditLimit = 1000

-- | Clamp a requested limit into @[1, maxAuditLimit]@. Both surfaces share this clamp.
clampLimit :: Int -> Int
clampLimit n = max 1 (min maxAuditLimit n)

data AuthEventReader :: Effect where
  QueryAuthEvents :: AuditEventQuery -> AuthEventReader m [StoredAuthEvent]
  CountAuthEvents :: AuditEventQuery -> AuthEventReader m Int

type instance DispatchOf AuthEventReader = Dynamic

queryAuthEvents :: (AuthEventReader :> es) => AuditEventQuery -> Eff es [StoredAuthEvent]
queryAuthEvents = send . QueryAuthEvents

countAuthEvents :: (AuthEventReader :> es) => AuditEventQuery -> Eff es Int
countAuthEvents = send . CountAuthEvents