Skip to content

LAYERED ARCHITECTURE — FLOW REFERENCE


Table of Contents

4c032c04 (docs: remove stale backend slop and reorganize AI/entity docs)


Knowledge Flows

FLOW 1: Create Knowledge

POST /api/knowledge

KnowledgeController::create()

KnowledgeOrchestrator::createKnowledge()
<<<<<<< HEAD
  ├─→ 1. RESOLVE & PREPARE: KnowledgeCreationService::createFromRequest()
  │     ├─→ Resolve category: CategoryService::resolve() (accepts local ID or Notion UUID string)
  │     ├─→ Extract URL content: ContentExtractionService::extractContent() (if URL present)
  │     ├─→ Merge content and resolve title (prefers extracted title if default used)
  │     └─→ Map DTO to entity: KnowledgeMapper::fromRequest()

  └─→ 2. PERSIST & SYNC: KnowledgePersistenceService::persist()
        ├─→ Save to local database: KnowledgeRepository::save() → SQLite
        └─→ Sync: NotionSyncService::syncKnowledgeToNotion() → Notion API (Strict: failures bubble up and fail the creation)
=======
  ├─→ 1. RESOLVE: CategoryResolutionService::resolve()
  ├─→ 2. MAP: KnowledgeMapper::fromCreateRequest()
  ├─→ 3. EXTRACT: ContentExtractionService::extractContent() (if URL present)
  ├─→ 4. PERSIST: KnowledgePersistenceService::create() → SQLite
  └─→ 5. SYNC: NotionSyncService::syncKnowledgeToNotion() → Notion API (best-effort)
>>>>>>> 4c032c04 (docs: remove stale backend slop and reorganize AI/entity docs)

FLOW 2: Fetch Knowledge (Planned / Internal-only)

NOTE

This flow is currently not exposed as a public API route (GET /api/knowledge/{id}) or handled by controllers. It serves as an internal pattern.

Internal ID Lookup

NotionRepositoryManager::getRepositoryForId()
  ├─→ Supports entity class and determines database resolver type
  ├─→ Numeric ID → KnowledgeRepository → SQLite
  └─→ Notion ID (String UUID) → NotionKnowledgeRepository → Notion API

FLOW 3: Sync Knowledge from Notion (Pull)

POST /api/notion/sync/from-notion

NotionSyncController::syncFromNotion()

NotionSyncService::syncKnowledgeFromNotion()
<<<<<<< HEAD
  ├─→ Fetch from Notion: NotionKnowledgeRepository::listAll() → Notion API
  ├─→ Map and upsert: KnowledgeRepository::save() → SQLite
  └─→ Cleanup: removeStaleLocalKnowledge() (deletes local knowledge whose Notion pages no longer exist)
=======
  ├─→ NotionKnowledgeRepository::findAll() → Notion API
  └─→ KnowledgePersistenceService::upsert() → SQLite
>>>>>>> 4c032c04 (docs: remove stale backend slop and reorganize AI/entity docs)

FLOW 4: Update Category Settings (Local-only)

WARNING

General category updates (PUT /api/categories/{id}) do not exist in the API layer. Category updates are restricted to digest configurations and are local-only (they do not sync back to Notion).

PUT /api/categories/{id}/settings/digest

<<<<<<< HEAD
CategorySettingsController::updateDigestConfig()
  ├─→ Load Category entity
  ├─→ Update fields: digestEnabled, digestFrequency, digestDay, digestTime
  └─→ Save changes: CategoryRepository::save() → SQLite
=======
CategoryController::update()

CategoryOrchestrator::updateCategory()
  ├─→ 1. RESOLVE: CategoryResolutionService::resolve()
  ├─→ 2. MAP: CategoryMapper::applyUpdateRequest()
  ├─→ 3. PERSIST: CategoryPersistenceService::update() → SQLite
  └─→ 4. SYNC: NotionSyncService::syncCategoryToNotion() → Notion API
>>>>>>> 4c032c04 (docs: remove stale backend slop and reorganize AI/entity docs)

Slack Input Flows

FLOW 5: Message Shortcut to Knowledge

User right-clicks message → "Opslaan in Knowledge Hub"

Slack Bot: handlers/shortcuts.ts (handleSaveMessageShortcut)

Modal opens with pre-filled content and URL (buildSaveMessageModal)

User submits form (Callback ID: save_message)

Slack Bot: handlers/submissions.ts (handleViewSubmission)
  ├─→ Extract message data (channelId, messageTs, text, user)
  ├─→ Merge message text with user comments
  └─→ Send: services/knowledge.ts (addKnowledge)

POST /api/knowledge (Backend API) → (Same as FLOW 1)

<<<<<<< HEAD
Response to Slack (ephemeral DM confirmation)
=======
KnowledgeOrchestrator::createKnowledge()
  ├─→ Extract URL content (if present)
  ├─→ KnowledgePersistenceService::create() → SQLite
  └─→ NotionSyncService::syncKnowledgeToNotion() → Notion API

Response to Slack (ephemeral confirmation)
>>>>>>> 4c032c04 (docs: remove stale backend slop and reorganize AI/entity docs)

FLOW 6: Global Shortcut (Quick Add)

User opens lightning bolt menu → "Snel toevoegen" (Quick Add Knowledge)

Slack Bot: handlers/shortcuts.ts (handleQuickAddKnowledgeShortcut)

Empty modal opens with fields (buildAddKnowledgeModal)

User submits form (Callback ID: add_knowledge)

Slack Bot: handlers/submissions.ts (handleViewSubmission)
  └─→ Send: services/knowledge.ts (addKnowledge)

POST /api/knowledge (Backend API) → (Same as FLOW 1)

FLOW 7: App Home Button to Knowledge

User clicks "➕ Kennis toevoegen" in App Home OR category menu

Slack Bot: handlers/actions.ts (handleActions / open_add_modal / home_add_knowledge)

Modal opens with form (buildAddKnowledgeModal)

User submits form (Callback ID: add_knowledge) → (Same as FLOW 6)

AI Processing Flows

FLOW 9: Generate AI Highlight (Lazy)

During digest generation, for each knowledge item:

KnowledgeHighlightGenerator::generateHighlight()

KnowledgeHighlightGenerator::ensureHighlightGenerated()
  ├─→ Check: Knowledge.highlight already exists?
  │     ├─→ Yes: Skip, use existing highlight
  │     └─→ No: Continue
  ├─→ Build prompt using DigestConstants::HIGHLIGHT_SUMMARY_PROMPT
  ├─→ Request: AiProviderInterface::generateCompletion(prompt, options)
  │     ├─→ Temperature: 0.3, Max tokens: 150
  │     └─→ Returns: 2-3 sentence Dutch summary
  └─→ Store in Knowledge.highlight (flushed during digest persistence)

FLOW 10: AI Fallback

KnowledgeHighlightGenerator::generateSummary()

AI completion call fails (timeout, API error, etc.)

Fallback: fallbackSummary()
  ├─→ Strip HTML tags: strip_tags(content)
  ├─→ Truncate using mb_substr to 200 characters
  └─→ Append ellipsis (...) if truncated

Store truncated text in Knowledge.highlight

Digest Flows

FLOW 11: Generate Digest (8-Step Pipeline)

POST /api/digests/generate
Body: { categoryId, preset: "7 days", distributeNow: true/false }

DigestController::generate()

DigestOrchestrator::generateDigest()

Step 1 — Resolve Category
  CategoryService::findById(categoryId)

Step 2 — Parse Date Range
  DateRangeParser::parse("7 days") → start/end dates

Step 3 — Query Knowledge
  KnowledgeRepository::findByCategoryAndDateRange(categoryId, start, end, limit)

Step 4 — Generate AI Highlights
  For each item: KnowledgeHighlightGenerator::generateHighlight() (lazy highlight creation)

Step 5 — Format Markdown
  DigestContentFormatter::formatDigestContent() → Dutch Markdown

Step 6 — Create Entity
  DigestCreationService::createFromGenerationData()
  - Set automatic title, statistics, snapshots, and Link Knowledge items

Step 7 — Persist & Sync
  DigestPersistenceService::persistAndSync()
<<<<<<< HEAD
  - Save to SQLite DB
  - NotionSyncService::syncDigestToNotion() → Notion API (Strict: failures bubble up)

Step 8 — Optional Immediate Distribution (if requested and initiator present)
  DigestDistributionService::distributeDigest() → (Trigger FLOW 12)
=======
  - Save to SQLite
  - NotionSyncService::syncDigestToNotion() → Notion API
>>>>>>> 4c032c04 (docs: remove stale backend slop and reorganize AI/entity docs)

FLOW 12: Distribute Digest to Subscribers

POST /api/digests/{id}/distribute
Body: { initiatorUserId: "UXXXXXX" }

DigestController::distribute()

DigestDistributionService::distributeDigest()

1. Resolve Recipients:
  ├─→ Query subscribers: SubscriptionQueryService::findActiveSubscriptionsWithUsers(categoryId)
  │     └─→ Formats names as "UserName (userId)" for Notion metadata representation
  ├─→ Add initiating user (if not already subscribed and not 'scheduler' bot)
  └─→ Deduplicate recipient user IDs

2. Deliver Messages:
  └─→ SlackDigestDeliveryService::sendBulk()
        ├─→ Convert Markdown → Slack Block Kit / mrkdwn layout
        ├─→ Send via Slack DM (Slack client)
        └─→ Log delivery status: DigestDeliveryLogService::logDelivery() → DigestDelivery entity

3. Update Status:
  ├─→ If successCount > 0, set status to 'sent'
  └─→ Notion Sync: NotionSyncService::syncDigestToNotion(..., isUpdate=true, recipients, wasDistributed)

FLOW 13: Retry Failed Deliveries

POST /api/digests/{id}/retry-failed

DigestController::retryFailed()

DigestDistributionService::retryFailedDeliveries()
  ├─→ Query failed DMs: DigestDeliveryQueryService::getRetryableDeliveries()
  │     └─→ DigestDeliveryRepository::findRetryable() (status = 'failed' AND retryCount < 3)
  └─→ For each delivery:
        ├─→ Re-attempt DM: SlackDigestDeliveryService::sendToUser()
        ├─→ If success → mark as sent in SQLite
        └─→ If fail → increment retryCount and update error log in SQLite

FLOW 14: Scheduled Digest (Cron Execution)

CLI Command: GenerateScheduledDigestsCommand

DigestSchedulerService::processScheduledDigests()

1. Candidate Search:
   CategoryRepository::findDigestEnabled() (all categories where digestEnabled = true)

2. Due check (for each category):
   Compare lastDigestAt + frequency interval vs current time AND match day of week and hour

3. Process & Generate (if due):
   Create GenerateDigestRequest(categoryId, preset, distributeNow: true, initiator: 'scheduler')

   DigestOrchestrator::generateDigest() (Trigger FLOW 11)

4. Skip Empty Check:
   If generated digest has 0 items:
     └─→ Skip distribution, log skipped state, do not send empty messages

Subscription Flows

FLOW 15: Subscribe to Category

User clicks "Subscribe" in category overflow menu

Slack Bot: handlers/actions.ts (OVERFLOW_ACTION_TYPES.SUBSCRIBE)
  ├─→ Fetch Slack user display name
  └─→ Send: services/subscriptions.ts (subscribeUserToCategory)

POST /api/subscriptions/subscribe

SubscriptionController::subscribe()

SubscriptionService::subscribeUser()
  ├─→ Check unique constraint (userId + categoryId)
  ├─→ If existing inactive subscription → reactivate() and update username
  ├─→ Else → SubscriptionPersistenceService::create()
  │     ├─→ Create entity and save to database → SQLite
  │     └─→ NotionSyncService::syncSubscriptionToNotion() → Notion API (Strict: failures bubble up)
  └─→ Refresh Slack Home tab

FLOW 16: Unsubscribe from Category

User clicks "Unsubscribe" in category overflow menu

Slack Bot: handlers/actions.ts (OVERFLOW_ACTION_TYPES.UNSUBSCRIBE)
  └─→ Send: services/subscriptions.ts (unsubscribeUserFromCategory)

POST /api/subscriptions/unsubscribe

SubscriptionController::unsubscribe()

SubscriptionService::unsubscribeUser()
  ├─→ Find active subscription
  ├─→ Deactivate subscription: SubscriptionPersistenceService::deactivate()
  │     ├─→ Soft-delete: isActive = false, unsubscribedAt = now
  │     └─→ NotionSyncService::syncSubscriptionToNotion(..., isUpdate=true) → Notion API (Strict: failures bubble up)
  └─→ Refresh Slack Home tab

Notion Sync Flows

FLOW 17: Push Local Entity to Notion

Triggered post-local changes in Persistence Services

<<<<<<< HEAD
NotionSyncService::sync{Entity}ToNotion(entity, isUpdate)
=======
NotionSyncService::sync{Entity}ToNotion(entity)
>>>>>>> 4c032c04 (docs: remove stale backend slop and reorganize AI/entity docs)

1. Build properties: NotionPageBuilder
2. Prevent Duplicates (Subscription-only):
   Checks if page exists in Notion for userId + categoryId; updates existing page instead of creating new
3. Save or Update Page:
   ├─→ If isUpdate or notionId present: Notion{Entity}Repository::update() → PATCH page
   └─→ If new: Notion{Entity}Repository::create() → POST new page
4. Markdown Content Formatting (Knowledge and Digest only):
   └─→ NotionMarkdownContentService::replaceMarkdown()
         └─→ Chunks and writes full content as blocks to bypass property limits
5. Save page info: Update entity with notionId + notionUrl → SQLite

FLOW 18: Pull Entities from Notion

POST /api/notion/sync/from-notion  OR  POST /api/notion/sync/categories-from-notion

NotionSyncController::syncFromNotion() / syncCategoriesFromNotion()

<<<<<<< HEAD
NotionSyncService::syncKnowledgeFromNotion() / syncCategoriesFromNotion()
  ├─→ Query: Fetch pages from Notion database via repository listAll()
  ├─→ Map: Convert Notion properties to local entity DTOs and hydration
  ├─→ Upsert: CategoryRepository/KnowledgeRepository::save() → SQLite
  └─→ Stale cleanup (Knowledge-only):
        └─→ removeStaleLocalKnowledge() (deletes local items that no longer exist in Notion database)

* NOTE: Pulling from Notion is supported for Knowledge and Category entities only. Digests and Subscriptions are only pushed to Notion.
=======
For each entity type (Knowledge, Category, Digest, Subscription):

  NotionSyncService::sync{Entity}FromNotion()
    ├─→ Query Notion pages changed since last sync
    ├─→ Map Notion properties to local entity
    └─→ Upsert to SQLite (create if new, update if changed)
>>>>>>> 4c032c04 (docs: remove stale backend slop and reorganize AI/entity docs)

Architecture Summary

Entities (5)

EntityTableKey Relationships
CategorycategoryOneToMany → Knowledge, Digest, Subscription
KnowledgeknowledgeManyToOne → Category
DigestdigestManyToOne → Category, ManyToMany → Knowledge
DigestDeliverydigest_deliveryManyToOne → Digest
SubscriptionsubscriptionManyToOne → Category

API Routes (18)

MethodRouteController / Action
GET/api/knowledgeKnowledgeController::list
POST/api/knowledgeKnowledgeController::create
GET/api/categoriesCategoryController::list
POST/api/categoriesCategoryController::create
GET/api/categories/{id}/settings/digestCategorySettingsController::getDigestConfig
PUT/api/categories/{id}/settings/digestCategorySettingsController::updateDigestConfig
POST/api/digests/generateDigestController::generate
POST/api/digests/{id}/distributeDigestController::distribute
GET/api/digests/{id}/delivery-statusDigestController::getDeliveryStatus
POST/api/digests/{id}/retry-failedDigestController::retryFailed
GET/api/digests/category/DigestController::getByCategory
POST/api/subscriptions/subscribeSubscriptionController::subscribe
POST/api/subscriptions/unsubscribeSubscriptionController::unsubscribe
GET/api/subscriptions/user/SubscriptionController::getUserSubscriptions
GET/api/subscriptions/category/{categoryId}/subscribersSubscriptionController::getCategorySubscribers
POST/api/notion/sync/from-notionNotionSyncController::syncFromNotion
POST/api/notion/sync/categories-from-notionNotionSyncController::syncCategoriesFromNotion
POST/api/notion/sync/to-notionNotionSyncController::sync