diff --git a/apps/api/backend/api/routes/events.py b/apps/api/backend/api/routes/events.py new file mode 100644 index 0000000..6ecefb4 --- /dev/null +++ b/apps/api/backend/api/routes/events.py @@ -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) \ No newline at end of file diff --git a/apps/api/backend/main.py b/apps/api/backend/main.py index a9273d8..72a8a27 100644 --- a/apps/api/backend/main.py +++ b/apps/api/backend/main.py @@ -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") @@ -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]: diff --git a/apps/api/backend/schemas/event_log.py b/apps/api/backend/schemas/event_log.py new file mode 100644 index 0000000..e52e023 --- /dev/null +++ b/apps/api/backend/schemas/event_log.py @@ -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 \ No newline at end of file diff --git a/apps/api/backend/services/event_log_service.py b/apps/api/backend/services/event_log_service.py new file mode 100644 index 0000000..f2ebf6b --- /dev/null +++ b/apps/api/backend/services/event_log_service.py @@ -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, + ) \ No newline at end of file diff --git a/apps/api/tests/test_event_logs.py b/apps/api/tests/test_event_logs.py new file mode 100644 index 0000000..4d3978a --- /dev/null +++ b/apps/api/tests/test_event_logs.py @@ -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" \ No newline at end of file diff --git a/db/ddl/schema.sql b/db/ddl/schema.sql index 63b727b..ef2a13b 100644 --- a/db/ddl/schema.sql +++ b/db/ddl/schema.sql @@ -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 -- ========================================= @@ -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); \ No newline at end of file +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); \ No newline at end of file diff --git a/db/migrations/20260522_add_event_logs.sql b/db/migrations/20260522_add_event_logs.sql new file mode 100644 index 0000000..116ede3 --- /dev/null +++ b/db/migrations/20260522_add_event_logs.sql @@ -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); \ No newline at end of file