LAYERED ARCHITECTURE — FLOW REFERENCE
Table of Contents
- Knowledge Flows (1-4) — Create, Fetch, Sync, Update <<<<<<< HEAD
- Slack Input Flows (5-7) — Shortcuts, Actions, App Home =======
- Slack Input Flows (5-7) — Shortcuts, App Home
4c032c04 (docs: remove stale backend slop and reorganize AI/entity docs)
- AI Processing Flows (9-10) — Highlight Generation, Fallback
- Digest Flows (11-14) — Generate, Distribute, Schedule
- Subscription Flows (15-16) — Subscribe, Unsubscribe
- Notion Sync Flows (17-18) — Push, Pull
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 APIFLOW 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.highlightDigest 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 SQLiteFLOW 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 messagesSubscription 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 tabFLOW 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 tabNotion 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 → SQLiteFLOW 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)
| Entity | Table | Key Relationships |
|---|---|---|
| Category | category | OneToMany → Knowledge, Digest, Subscription |
| Knowledge | knowledge | ManyToOne → Category |
| Digest | digest | ManyToOne → Category, ManyToMany → Knowledge |
| DigestDelivery | digest_delivery | ManyToOne → Digest |
| Subscription | subscription | ManyToOne → Category |
API Routes (18)
| Method | Route | Controller / Action |
|---|---|---|
| GET | /api/knowledge | KnowledgeController::list |
| POST | /api/knowledge | KnowledgeController::create |
| GET | /api/categories | CategoryController::list |
| POST | /api/categories | CategoryController::create |
| GET | /api/categories/{id}/settings/digest | CategorySettingsController::getDigestConfig |
| PUT | /api/categories/{id}/settings/digest | CategorySettingsController::updateDigestConfig |
| POST | /api/digests/generate | DigestController::generate |
| POST | /api/digests/{id}/distribute | DigestController::distribute |
| GET | /api/digests/{id}/delivery-status | DigestController::getDeliveryStatus |
| POST | /api/digests/{id}/retry-failed | DigestController::retryFailed |
| GET | /api/digests/category/ | DigestController::getByCategory |
| POST | /api/subscriptions/subscribe | SubscriptionController::subscribe |
| POST | /api/subscriptions/unsubscribe | SubscriptionController::unsubscribe |
| GET | /api/subscriptions/user/ | SubscriptionController::getUserSubscriptions |
| GET | /api/subscriptions/category/{categoryId}/subscribers | SubscriptionController::getCategorySubscribers |
| POST | /api/notion/sync/from-notion | NotionSyncController::syncFromNotion |
| POST | /api/notion/sync/categories-from-notion | NotionSyncController::syncCategoriesFromNotion |
| POST | /api/notion/sync/to-notion | NotionSyncController::sync |