Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/api/backend/api/routes/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from fastapi import APIRouter, status

from backend.schemas.event_log import (
EventLogCreateRequest,
EventLogCreateResponse,
)
from backend.services.event_log_service import create_event_log

router = APIRouter(prefix="/events", tags=["events"])


@router.post(
"",
response_model=EventLogCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_event(payload: EventLogCreateRequest) -> EventLogCreateResponse:
result = create_event_log(payload)
return EventLogCreateResponse(**result)
3 changes: 3 additions & 0 deletions apps/api/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from backend.api.routes.products import router as products_router
from backend.api.routes.reviews import router as reviews_router
from backend.api.routes.sessions import router as sessions_router
from backend.api.routes.events import router as events_router


app = FastAPI(title="D2C Commerce Prototype API")

Expand Down Expand Up @@ -45,6 +47,7 @@
app.include_router(order_history_router)
app.include_router(payments_router)
app.include_router(reviews_router)
app.include_router(events_router)

@app.get("/health")
def health_check() -> dict[str, str]:
Expand Down
31 changes: 31 additions & 0 deletions apps/api/backend/schemas/event_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from datetime import datetime
from typing import Any
from uuid import UUID

from pydantic import BaseModel, Field


class EventLogCreateRequest(BaseModel):
event_name: str = Field(..., min_length=1, max_length=100)
event_type: str = Field(..., min_length=1, max_length=50)
user_id: UUID | None = None
session_id: UUID | None = None
entity_type: str | None = Field(default=None, max_length=50)
entity_id: UUID | None = None
source: str = Field(..., min_length=1, max_length=50)
properties: dict[str, Any] = Field(default_factory=dict)


class EventLogCreateResponse(BaseModel):
event_id: UUID
event_name: str
event_type: str
user_id: UUID | None = None
session_id: UUID | None = None
entity_type: str | None = None
entity_id: UUID | None = None
occurred_at: datetime
source: str
properties: dict[str, Any]
created_at: datetime
message: str
121 changes: 121 additions & 0 deletions apps/api/backend/services/event_log_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import json
from typing import Any
from uuid import UUID, uuid4

from fastapi import HTTPException, status
from sqlalchemy import text

from backend.db.connection import engine
from backend.schemas.event_log import EventLogCreateRequest


ALLOWED_EVENT_TYPES = {"user_behavior", "domain_event", "system_event"}
ALLOWED_SOURCES = {"frontend", "backend", "script"}


def record_event(
event_name: str,
event_type: str,
source: str,
*,
user_id: UUID | None = None,
session_id: UUID | None = None,
entity_type: str | None = None,
entity_id: UUID | None = None,
properties: dict[str, Any] | None = None,
) -> dict[str, Any]:
if event_type not in ALLOWED_EVENT_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unsupported event type",
)

if source not in ALLOWED_SOURCES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unsupported event source",
)

event_id = uuid4()
normalized_properties = properties or {}

insert_query = text("""
INSERT INTO event_logs (
event_id,
event_name,
event_type,
user_id,
session_id,
entity_type,
entity_id,
occurred_at,
source,
properties,
created_at
)
VALUES (
:event_id,
:event_name,
:event_type,
:user_id,
:session_id,
:entity_type,
:entity_id,
CURRENT_TIMESTAMP,
:source,
CAST(:properties AS JSONB),
CURRENT_TIMESTAMP
)
RETURNING
event_id,
event_name,
event_type,
user_id,
session_id,
entity_type,
entity_id,
occurred_at,
source,
properties,
created_at
""")

with engine.begin() as connection:
event = connection.execute(
insert_query,
{
"event_id": event_id,
"event_name": event_name,
"event_type": event_type,
"user_id": user_id,
"session_id": session_id,
"entity_type": entity_type,
"entity_id": entity_id,
"source": source,
"properties": json.dumps(normalized_properties, default=str),
},
).mappings().first()

if event is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to record event log",
)

return {
**dict(event),
"message": "Event log recorded successfully",
}


def create_event_log(payload: EventLogCreateRequest) -> dict[str, Any]:
return record_event(
event_name=payload.event_name,
event_type=payload.event_type,
source=payload.source,
user_id=payload.user_id,
session_id=payload.session_id,
entity_type=payload.entity_type,
entity_id=payload.entity_id,
properties=payload.properties,
)
83 changes: 83 additions & 0 deletions apps/api/tests/test_event_logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from fastapi.testclient import TestClient

from backend.main import app

client = TestClient(app)


def test_create_event_log_returns_201() -> None:
response = client.post(
"/events",
json={
"event_name": "product_detail_viewed",
"event_type": "user_behavior",
"source": "frontend",
"user_id": None,
"session_id": None,
"entity_type": "product",
"entity_id": "33333333-3333-3333-3333-000000000001",
"properties": {
"product_name": "Accessory Dock Pro",
"source_page": "product_list",
},
},
)

assert response.status_code == 201


def test_create_event_log_returns_expected_fields() -> None:
response = client.post(
"/events",
json={
"event_name": "product_detail_viewed",
"event_type": "user_behavior",
"source": "frontend",
"entity_type": "product",
"entity_id": "33333333-3333-3333-3333-000000000001",
"properties": {
"product_name": "Accessory Dock Pro",
},
},
)
data = response.json()

assert "event_id" in data
assert data["event_name"] == "product_detail_viewed"
assert data["event_type"] == "user_behavior"
assert data["source"] == "frontend"
assert data["entity_type"] == "product"
assert data["entity_id"] == "33333333-3333-3333-3333-000000000001"
assert "occurred_at" in data
assert "created_at" in data
assert data["message"] == "Event log recorded successfully"


def test_create_event_log_returns_400_for_unsupported_event_type() -> None:
response = client.post(
"/events",
json={
"event_name": "product_detail_viewed",
"event_type": "invalid_type",
"source": "frontend",
"properties": {},
},
)

assert response.status_code == 400
assert response.json()["detail"] == "Unsupported event type"


def test_create_event_log_returns_400_for_unsupported_source() -> None:
response = client.post(
"/events",
json={
"event_name": "product_detail_viewed",
"event_type": "user_behavior",
"source": "invalid_source",
"properties": {},
},
)

assert response.status_code == 400
assert response.json()["detail"] == "Unsupported event source"
34 changes: 32 additions & 2 deletions db/ddl/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,24 @@ CREATE TABLE reviews (
ON DELETE RESTRICT
);

-- =========================================
-- 13. event_logs
-- =========================================
CREATE TABLE IF NOT EXISTS event_logs (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_name VARCHAR(100) NOT NULL,
event_type VARCHAR(50) NOT NULL,
user_id UUID NULL,
session_id UUID NULL,
entity_type VARCHAR(50) NULL,
entity_id UUID NULL,
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
source VARCHAR(50) NOT NULL,
properties JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);


-- =========================================
-- Recommended indexes
-- =========================================
Expand All @@ -263,9 +281,21 @@ CREATE INDEX idx_cart_items_cart_id ON cart_items(cart_id);
CREATE INDEX idx_cart_items_product_id ON cart_items(product_id);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_cart_id ON orders(cart_id);
CREATE INDEX idx_orders_coupon_id ON orders(applied_coupon_id);
CREATE INDEX idx_orders_coupon_id ON orders(coupon_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);
CREATE INDEX idx_payments_order_id ON payments(order_id);
CREATE INDEX idx_reviews_user_id ON reviews(user_id);
CREATE INDEX idx_reviews_product_id ON reviews(product_id);
CREATE INDEX idx_reviews_product_id ON reviews(product_id);

CREATE INDEX IF NOT EXISTS idx_event_logs_event_name ON event_logs (event_name);

CREATE INDEX IF NOT EXISTS idx_event_logs_event_type ON event_logs (event_type);

CREATE INDEX IF NOT EXISTS idx_event_logs_user_id ON event_logs (user_id);

CREATE INDEX IF NOT EXISTS idx_event_logs_session_id ON event_logs (session_id);

CREATE INDEX IF NOT EXISTS idx_event_logs_entity ON event_logs (entity_type, entity_id);

CREATE INDEX IF NOT EXISTS idx_event_logs_occurred_at ON event_logs (occurred_at);
31 changes: 31 additions & 0 deletions db/migrations/20260522_add_event_logs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
CREATE TABLE IF NOT EXISTS event_logs (
event_id UUID PRIMARY KEY,
event_name VARCHAR(100) NOT NULL,
event_type VARCHAR(50) NOT NULL,
user_id UUID NULL,
session_id UUID NULL,
entity_type VARCHAR(50) NULL,
entity_id UUID NULL,
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
source VARCHAR(50) NOT NULL,
properties JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_event_logs_event_name
ON event_logs (event_name);

CREATE INDEX IF NOT EXISTS idx_event_logs_event_type
ON event_logs (event_type);

CREATE INDEX IF NOT EXISTS idx_event_logs_user_id
ON event_logs (user_id);

CREATE INDEX IF NOT EXISTS idx_event_logs_session_id
ON event_logs (session_id);

CREATE INDEX IF NOT EXISTS idx_event_logs_entity
ON event_logs (entity_type, entity_id);

CREATE INDEX IF NOT EXISTS idx_event_logs_occurred_at
ON event_logs (occurred_at);
Loading