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