Skip to content

API — Code Flow

This page explains the runtime sequence implemented by the API service code.

Bootstrap & request routing

When the application starts, NestJS creates the HTTP adapter with bodyParser explicitly disabled. This is not optional — the better-auth adapter requires direct access to the raw request stream on auth routes, and a pre-parsed body would break it. After that, CORS is enabled with credentials: true so the dashboard at localhost:3000 can attach session cookies to cross-origin requests. A global route prefix of /api is then registered, so every endpoint in every module is automatically namespaced. Finally, ConfigService is consulted for the PORT value and the server begins listening.

Authentication flow

The better-auth library is initialized in a standalone auth.ts file outside the NestJS module tree, then handed to AuthModule.forRoot() in the root module. From that point, the nestjs-better-auth adapter intercepts all requests matching /api/auth/* before NestJS controllers see them. When a magic link is requested, better-auth generates a signed verification URL using BETTER_AUTH_SECRET and the configured BASE_URL. In the current implementation, the URL is written to stdout via a sendMagicLink stub rather than delivered by email. Once a user visits the link, better-auth creates a session and issues a cookie. Subsequent requests carry that cookie, which the adapter verifies independently.

Content listing & filtering

Requests arriving at GET /api/contents are first validated against a Zod schema (ListQuerySchema) before the service layer is reached. The schema coerces and bounds-checks page, limit, dateFrom, dateTo, kategori, and title query parameters. The service then constructs a TypeORM QueryBuilder on ContentEntity, adding WHERE clauses only for the filters that were actually supplied. A title filter translates to a case-insensitive ILIKE '%value%' on the baslik column. All results are ordered by yayim_tarihi DESC. The response shape is always { items, total, page, limit, pages }, where pages is computed from total and limit.

Single content & predictions

When a single item is fetched via GET /api/contents/:id, the service performs two separate database queries. First it loads the ContentEntity by its UUID primary key. Then it queries ContentResultEntity filtering on source_id (not the UUID primary key — the shared identifier used across services), ordering by created_at DESC. This means the response includes not just the article record but the complete prediction history: every time the item was run through the model, a new result row was appended, and all of them are returned. The two records are merged into a single response object before being sent.

Pending content queue

GET /api/contents/pending queries for rows where model_kategori IS NULL and parsed = true. The parsed flag indicates the producer has already fetched and stored the full article body, and model_kategori being null means the consumer has not yet classified it — or classification failed and no result was written back. The query is capped at 100 rows. This endpoint exists primarily to let the dashboard surface a backlog of items that can be manually requeued for re-classification.

Manual category override

PATCH /api/contents/:id/category writes directly to the kategori column of ContentEntity. It does not touch model_kategori. This separation is intentional: kategori holds the human-assigned label and model_kategori holds what the model predicted. The analytics comparison endpoint (/api/analytics/comparison) depends on both columns being independently maintained to compute agreement rates. The patch accepts an integer between 1 and 7, corresponding to the Kategori enum (POLITIKA=1 through TEKNOLOJI=7).

Requeue flow

POST /api/contents/requeue accepts a JSON body containing an array of content UUIDs (1 to 100). Because bodyParser is disabled globally, the controller streams the raw request body and parses it manually before proceeding. For each UUID, the service loads the corresponding ContentEntity and checks that both baslik (title) and ozet (summary) are non-null. Items missing either field are skipped silently. For qualifying items, the service emits { id: source_id, baslik, ozet } to the raw_content_aa Kafka topic via KafkaService. The consumer listens on this topic and will re-run model inference on receipt. The endpoint returns { requeued, ids } where requeued is the count of items actually emitted.

Fetch trigger

POST /api/trigger is the simplest endpoint in the service. It calls KafkaService.emit('fetch_content_aa', { timestamp: Date.now() }) and returns { triggered: true }. The producer service has a Kafka consumer that listens on fetch_content_aa; receiving any message on that topic causes it to start a new AA API fetch run immediately, bypassing its internal cron schedule. No request body is required or read.

Real-time event stream (SSE)

SseModule wires together two classes. SseService holds a single RxJS Subject<MessageEvent> and exposes it as a public events$ observable. SseConsumer is a NestJS service that, on module initialization, creates a raw KafkaJS consumer with clientId: "api-sse" and groupId: "api-sse-group". It subscribes to processed_content_aa with fromBeginning: false, so only messages arriving after the service starts are forwarded. For each Kafka message, the consumer parses the JSON payload and calls sseService.push({ data: payload, type: "processed_content" }), which emits into the Subject. SseController exposes GET /api/events as an @Sse() endpoint that returns sseService.events$ directly — NestJS handles the HTTP chunked encoding and SSE framing. Raw KafkaJS is used here instead of the shared KafkaModule (which uses @nestjs/microservices ClientKafka) specifically to avoid registering a second microservice transport on the application.

Analytics queries

Every analytics endpoint runs either a raw SQL query or a TypeORM QueryBuilder aggregation directly against the shared Postgres tables, with no caching layer in between. getCategoryWeights groups by kategori and counts rows. getDailyProcessed groups by DATE(processed_at) for the last 30 days. getModelTrust uses a PostgreSQL PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY confidence) expression on content_results to return the median confidence score. getCategoryComparison counts rows where both kategori and model_kategori are non-null (the denominator) and then counts the subset where they are equal (the matches), returning { total, matches, mismatches, agreement_rate }. getConsumerAnalytics aggregates average processing latency and failure rate across ConsumerAnalyticsEntity rows, then appends the latest 50 raw rows for inspection.

Implementation notes

The bodyParser: false global setting means any route that accepts a request body must manually read and parse it — a parseBody() helper is called at the top of affected controller methods. This applies to the requeue and category patch endpoints; the trigger endpoint takes no body and is unaffected. The separation between KafkaModule/KafkaService (used for producing) and the raw KafkaJS consumer in SseConsumer (used for consuming) is deliberate: mixing NestJS microservice transports with direct KafkaJS consumers in the same process would require starting a second transport, which conflicts with the HTTP-first application bootstrap. All Kafka topics must be created externally before the service starts, as allowAutoTopicCreation is disabled for both the NestJS client and the KafkaJS consumer.