@@ -314,6 +314,9 @@
+
@@ -596,11 +599,22 @@ window.addEventListener('unhandledrejection', function(event) {
document.addEventListener('DOMContentLoaded', () => {
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
const searchBubbleBtn = document.getElementById('globalSearchBtn');
+ const contextManualBtn = document.getElementById('contextManualBtn');
const remindersBubbleBtn = document.getElementById('globalRemindersBtn');
const profileModalEl = document.getElementById('profileModal');
const profileModalInstance = profileModalEl ? new bootstrap.Modal(profileModalEl) : null;
const globalSearchInput = document.getElementById('globalSearchInput');
+ function getCurrentModuleContext() {
+ const path = (window.location.pathname || '').toLowerCase();
+ if (path.startsWith('/sag')) return 'sag';
+ if (path.startsWith('/hardware')) return 'hardware';
+ if (path.startsWith('/emails')) return 'mail';
+ if (path.startsWith('/ordre')) return 'salg';
+ if (path.startsWith('/customers') || path.startsWith('/contacts')) return 'crm';
+ return '';
+ }
+
function openGlobalSearchModal() {
searchModal.show();
setTimeout(() => {
@@ -640,6 +654,18 @@ window.addEventListener('unhandledrejection', function(event) {
openRemindersModalTab();
});
}
+
+ if (contextManualBtn) {
+ contextManualBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ const module = getCurrentModuleContext();
+ if (module) {
+ window.location.href = `/manual?module=${encodeURIComponent(module)}`;
+ return;
+ }
+ window.location.href = '/manual';
+ });
+ }
// Search input listener with debounce
let searchTimeout;
diff --git a/main.py b/main.py
index 393662d..3b2963e 100644
--- a/main.py
+++ b/main.py
@@ -131,6 +131,8 @@ from app.modules.calendar.backend import router as calendar_api
from app.modules.calendar.frontend import views as calendar_views
from app.modules.orders.backend import router as orders_api
from app.modules.orders.frontend import views as orders_views
+from app.modules.manual.backend import router as manual_api
+from app.modules.manual.frontend import views as manual_views
# Configure logging
logging.basicConfig(
@@ -427,6 +429,7 @@ app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devp
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
+app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api
@@ -460,6 +463,7 @@ app.include_router(telefoni_views.router, tags=["Frontend"])
app.include_router(calendar_views.router, tags=["Frontend"])
app.include_router(orders_views.router, tags=["Frontend"])
app.include_router(anydesk_views.router, tags=["Frontend"])
+app.include_router(manual_views.router, tags=["Frontend"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views
diff --git a/migrations/161_manual_module.sql b/migrations/161_manual_module.sql
new file mode 100644
index 0000000..d32084b
--- /dev/null
+++ b/migrations/161_manual_module.sql
@@ -0,0 +1,73 @@
+-- 161_manual_module.sql
+-- Manual module: articles, steps and contextual relations
+
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+
+CREATE TABLE IF NOT EXISTS manual_articles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ title VARCHAR(255) NOT NULL,
+ slug VARCHAR(255) NOT NULL UNIQUE,
+ content TEXT NOT NULL,
+ summary TEXT,
+ module VARCHAR(80) NOT NULL,
+ tags JSONB NOT NULL DEFAULT '[]'::jsonb,
+ difficulty VARCHAR(20) NOT NULL DEFAULT 'beginner' CHECK (difficulty IN ('beginner', 'advanced')),
+ use_count INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL
+);
+
+CREATE TABLE IF NOT EXISTS manual_steps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ manual_id UUID NOT NULL REFERENCES manual_articles(id) ON DELETE CASCADE,
+ step_number INTEGER NOT NULL CHECK (step_number > 0),
+ title VARCHAR(255) NOT NULL,
+ content TEXT NOT NULL,
+ image_url TEXT,
+ video_url TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE (manual_id, step_number)
+);
+
+CREATE TABLE IF NOT EXISTS manual_relations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ manual_id UUID NOT NULL REFERENCES manual_articles(id) ON DELETE CASCADE,
+ related_module VARCHAR(80),
+ related_tag VARCHAR(120),
+ related_sag_type VARCHAR(120),
+ related_manual_id UUID REFERENCES manual_articles(id) ON DELETE SET NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_manual_articles_module ON manual_articles(module);
+CREATE INDEX IF NOT EXISTS idx_manual_articles_difficulty ON manual_articles(difficulty);
+CREATE INDEX IF NOT EXISTS idx_manual_articles_deleted_at ON manual_articles(deleted_at);
+CREATE INDEX IF NOT EXISTS idx_manual_articles_use_count ON manual_articles(use_count DESC);
+CREATE INDEX IF NOT EXISTS idx_manual_steps_manual_id_step ON manual_steps(manual_id, step_number);
+CREATE INDEX IF NOT EXISTS idx_manual_relations_manual_id ON manual_relations(manual_id);
+CREATE INDEX IF NOT EXISTS idx_manual_relations_related_module ON manual_relations(related_module);
+CREATE INDEX IF NOT EXISTS idx_manual_relations_related_tag ON manual_relations(related_tag);
+CREATE INDEX IF NOT EXISTS idx_manual_relations_related_sag_type ON manual_relations(related_sag_type);
+
+-- Keep article updated_at fresh on content changes.
+CREATE OR REPLACE FUNCTION bump_manual_article_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_manual_articles_updated_at ON manual_articles;
+CREATE TRIGGER trg_manual_articles_updated_at
+BEFORE UPDATE ON manual_articles
+FOR EACH ROW
+EXECUTE FUNCTION bump_manual_article_updated_at();
+
+DROP TRIGGER IF EXISTS trg_manual_steps_updated_at ON manual_steps;
+CREATE TRIGGER trg_manual_steps_updated_at
+BEFORE UPDATE ON manual_steps
+FOR EACH ROW
+EXECUTE FUNCTION bump_manual_article_updated_at();
diff --git a/migrations/162_seed_manual_articles.sql b/migrations/162_seed_manual_articles.sql
new file mode 100644
index 0000000..420cf0a
--- /dev/null
+++ b/migrations/162_seed_manual_articles.sql
@@ -0,0 +1,164 @@
+-- 162_seed_manual_articles.sql
+-- Seed starter manuals for the Manual module (idempotent)
+
+-- 1) Core manual articles
+INSERT INTO manual_articles (title, slug, content, summary, module, tags, difficulty)
+VALUES
+(
+ 'Hvordan opretter jeg en sag?',
+ 'hvordan-opretter-jeg-en-sag',
+ '# Opret en ny sag\n\nDenne guide viser den hurtigste vej til at oprette en sag i BMC Hub.\n\n## Hurtig version\n1. Klik på plus-ikonet i topbaren.\n2. Vælg sag-oprettelse.\n3. Udfyld titel, kunde og beskrivelse.\n4. Sæt status og ansvarlig.\n5. Gem sagen.\n\n## Gode vaner\n- Brug en præcis titel, så sagen kan findes senere.\n- Tilføj tags med det samme for bedre filtrering.\n- Vælg korrekt prioritet fra start.',
+ 'Trin-for-trin guide til at oprette en ny sag korrekt og hurtigt.',
+ 'sag',
+ '["sag", "opgave", "ticket", "opret"]'::jsonb,
+ 'beginner'
+),
+(
+ 'Sådan finder og opdaterer du hardware',
+ 'saadan-finder-og-opdaterer-du-hardware',
+ '# Find og opdater hardware\n\nGuiden hjælper dig med at finde et hardware-asset og opdatere nøglefelter uden fejl.\n\n## Hvad du bør opdatere\n- Serienummer\n- Ejer/kunde\n- Lokation\n- Status\n\n## Tip\nHvis du ikke finder enheden via navn, så prøv serienummer eller ESET UUID.',
+ 'Find, verificer og opdatér hardwaredata i supportflowet.',
+ 'hardware',
+ '["hardware", "asset", "enhed", "serial"]'::jsonb,
+ 'beginner'
+),
+(
+ 'Mail: sådan finder du en tråd og følger op',
+ 'mail-saadan-finder-du-en-traad-og-foelger-op',
+ '# Mail workflow\n\nBrug denne guide når du skal finde en mailtråd, forstå historik og sende opfølgning.\n\n## Fokus\n- Find korrekt tråd\n- Tjek vedhæftninger\n- Link til sag\n- Send tydelig opfølgning\n\n## Tip\nBrug emnelinje + kunde som første filter ved søgning.',
+ 'Praktisk flow for mailbehandling og opfølgning i Hubben.',
+ 'mail',
+ '["mail", "email", "traad", "opfoelgning"]'::jsonb,
+ 'advanced'
+)
+ON CONFLICT (slug) DO UPDATE
+SET
+ title = EXCLUDED.title,
+ content = EXCLUDED.content,
+ summary = EXCLUDED.summary,
+ module = EXCLUDED.module,
+ tags = EXCLUDED.tags,
+ difficulty = EXCLUDED.difficulty,
+ updated_at = CURRENT_TIMESTAMP,
+ deleted_at = NULL;
+
+-- 2) Steps for "Hvordan opretter jeg en sag?"
+WITH target AS (
+ SELECT id FROM manual_articles WHERE slug = 'hvordan-opretter-jeg-en-sag' LIMIT 1
+)
+INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
+SELECT
+ target.id,
+ s.step_number,
+ s.title,
+ s.content,
+ s.image_url,
+ s.video_url
+FROM target
+CROSS JOIN (
+ VALUES
+ (1, 'Klik på +', 'Brug plus-knappen i topbaren til at starte oprettelse af ny sag.', NULL, NULL),
+ (2, 'Vælg sag-oprettelse', 'Vælg oprettelsesflow for sag, ikke ordre eller anden type.', NULL, NULL),
+ (3, 'Udfyld kernefelter', 'Angiv titel, kunde, beskrivelse og ansvarlig bruger.', NULL, NULL),
+ (4, 'Sæt status og prioritet', 'Sæt status til fx "åben" og vælg en prioritet der passer.', NULL, NULL),
+ (5, 'Gem sagen', 'Klik gem og kontrollér at sagen vises i listen.', NULL, NULL)
+) AS s(step_number, title, content, image_url, video_url)
+ON CONFLICT (manual_id, step_number) DO UPDATE
+SET
+ title = EXCLUDED.title,
+ content = EXCLUDED.content,
+ image_url = EXCLUDED.image_url,
+ video_url = EXCLUDED.video_url,
+ updated_at = CURRENT_TIMESTAMP;
+
+-- 3) Steps for hardware manual
+WITH target AS (
+ SELECT id FROM manual_articles WHERE slug = 'saadan-finder-og-opdaterer-du-hardware' LIMIT 1
+)
+INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
+SELECT
+ target.id,
+ s.step_number,
+ s.title,
+ s.content,
+ s.image_url,
+ s.video_url
+FROM target
+CROSS JOIN (
+ VALUES
+ (1, 'Åbn hardwaremodulet', 'Gå til Support -> Hardware for at se assets.', NULL, NULL),
+ (2, 'Søg enheden frem', 'Søg på serienummer, model eller kundenavn.', NULL, NULL),
+ (3, 'Åbn detaljesiden', 'Klik på enheden og kontroller stamdata.', NULL, NULL),
+ (4, 'Ret felter', 'Opdater fx lokation, ejer og status og gem ændringer.', NULL, NULL)
+) AS s(step_number, title, content, image_url, video_url)
+ON CONFLICT (manual_id, step_number) DO UPDATE
+SET
+ title = EXCLUDED.title,
+ content = EXCLUDED.content,
+ image_url = EXCLUDED.image_url,
+ video_url = EXCLUDED.video_url,
+ updated_at = CURRENT_TIMESTAMP;
+
+-- 4) Steps for mail manual
+WITH target AS (
+ SELECT id FROM manual_articles WHERE slug = 'mail-saadan-finder-du-en-traad-og-foelger-op' LIMIT 1
+)
+INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
+SELECT
+ target.id,
+ s.step_number,
+ s.title,
+ s.content,
+ s.image_url,
+ s.video_url
+FROM target
+CROSS JOIN (
+ VALUES
+ (1, 'Åbn emailmodulet', 'Gå til Email i hovedmenuen.', NULL, NULL),
+ (2, 'Søg på tråd', 'Søg på emne, afsender eller kunde.', NULL, NULL),
+ (3, 'Kontrollér historik', 'Læs hele tråden og tjek vedhæftninger.', NULL, NULL),
+ (4, 'Link til sag', 'Link mailtråden til relevant sag hvis den findes.', NULL, NULL),
+ (5, 'Send opfølgning', 'Svar kort og tydeligt med næste handling og tidspunkt.', NULL, NULL)
+) AS s(step_number, title, content, image_url, video_url)
+ON CONFLICT (manual_id, step_number) DO UPDATE
+SET
+ title = EXCLUDED.title,
+ content = EXCLUDED.content,
+ image_url = EXCLUDED.image_url,
+ video_url = EXCLUDED.video_url,
+ updated_at = CURRENT_TIMESTAMP;
+
+-- 5) Context relations
+INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
+SELECT m.id, 'sag', 'sag', 'ticket', NULL
+FROM manual_articles m
+WHERE m.slug = 'hvordan-opretter-jeg-en-sag'
+ AND NOT EXISTS (
+ SELECT 1 FROM manual_relations r
+ WHERE r.manual_id = m.id
+ AND COALESCE(r.related_module, '') = 'sag'
+ AND COALESCE(r.related_tag, '') = 'sag'
+ AND COALESCE(r.related_sag_type, '') = 'ticket'
+ );
+
+INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
+SELECT m.id, 'hardware', 'hardware', NULL, NULL
+FROM manual_articles m
+WHERE m.slug = 'saadan-finder-og-opdaterer-du-hardware'
+ AND NOT EXISTS (
+ SELECT 1 FROM manual_relations r
+ WHERE r.manual_id = m.id
+ AND COALESCE(r.related_module, '') = 'hardware'
+ AND COALESCE(r.related_tag, '') = 'hardware'
+ );
+
+INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
+SELECT m.id, 'mail', 'mail', NULL, NULL
+FROM manual_articles m
+WHERE m.slug = 'mail-saadan-finder-du-en-traad-og-foelger-op'
+ AND NOT EXISTS (
+ SELECT 1 FROM manual_relations r
+ WHERE r.manual_id = m.id
+ AND COALESCE(r.related_module, '') = 'mail'
+ AND COALESCE(r.related_tag, '') = 'mail'
+ );
diff --git a/migrations/163_normalize_manual_newlines.sql b/migrations/163_normalize_manual_newlines.sql
new file mode 100644
index 0000000..367b650
--- /dev/null
+++ b/migrations/163_normalize_manual_newlines.sql
@@ -0,0 +1,16 @@
+-- 163_normalize_manual_newlines.sql
+-- Normalize legacy literal "\\n" sequences in manual text fields to real newlines.
+
+UPDATE manual_articles
+SET
+ content = REPLACE(content, E'\\n', E'\n'),
+ summary = REPLACE(COALESCE(summary, ''), E'\\n', E'\n'),
+ updated_at = CURRENT_TIMESTAMP
+WHERE POSITION(E'\\n' IN content) > 0
+ OR POSITION(E'\\n' IN COALESCE(summary, '')) > 0;
+
+UPDATE manual_steps
+SET
+ content = REPLACE(content, E'\\n', E'\n'),
+ updated_at = CURRENT_TIMESTAMP
+WHERE POSITION(E'\\n' IN content) > 0;
diff --git a/migrations/164_seed_manual_articles_more.sql b/migrations/164_seed_manual_articles_more.sql
new file mode 100644
index 0000000..79615ef
--- /dev/null
+++ b/migrations/164_seed_manual_articles_more.sql
@@ -0,0 +1,163 @@
+-- 164_seed_manual_articles_more.sql
+-- Seed additional manuals for sag/mail/workflow usage (idempotent)
+
+-- 1) Additional manual articles
+INSERT INTO manual_articles (title, slug, content, summary, module, tags, difficulty)
+VALUES
+(
+ 'Sådan bruger du tags i sager',
+ 'saadan-bruger-du-tags-i-sager',
+ '# Brug tags aktivt i sager\n\nTags gør det nemmere at filtrere, finde og automatisere sager.\n\n## Når du opretter en sag\n- Tilføj mindst ét type-tag\n- Tilføj evt. brand-tag\n- Undgå dubletter og næsten-identiske tags\n\n## Efterfølgende\n- Brug tag-filter i lister\n- Hold tags opdateret når sagen ændrer retning',
+ 'Guide til bedre struktur og hurtigere søgning med tags i sag-modulet.',
+ 'sag',
+ '["sag", "tags", "filtrering", "workflow"]'::jsonb,
+ 'beginner'
+),
+(
+ 'Sådan linker du mail til sag',
+ 'saadan-linker-du-mail-til-sag',
+ '# Link mailtråde til sager\n\nNår mail og sag er koblet, får du bedre historik og hurtigere opfølgning.\n\n## Hvornår skal du linke\n- Når mailen handler om en eksisterende sag\n- Når der opstår ny opgave i tråden\n\n## Fordel\nHele teamet kan se samme kontekst uden at lede i flere moduler.',
+ 'Praktisk flow for at koble email-tråde korrekt til sager.',
+ 'mail',
+ '["mail", "sag", "link", "email", "ticket"]'::jsonb,
+ 'beginner'
+),
+(
+ 'Reminders og deferred status i sag-modulet',
+ 'reminders-og-deferred-status-i-sag-modulet',
+ '# Brug reminders og deferred korrekt\n\nReminders og deferred hjælper med at holde fokus på det rigtige tidspunkt.\n\n## Reminder bruges til\n- Næste handling på bestemt dato\n- Husk-opgaver uden statusskifte\n\n## Deferred bruges til\n- Vent på ekstern part\n- Genåbn eller aktiver ved bestemt status-trigger\n\n## Best practice\nSkriv altid kort hvorfor sagen er deferred, så næste kollega kan tage over.',
+ 'Guide til at styre ventende sager med reminders og deferred triggers.',
+ 'sag',
+ '["sag", "reminder", "deferred", "status", "opfoelgning"]'::jsonb,
+ 'advanced'
+)
+ON CONFLICT (slug) DO UPDATE
+SET
+ title = EXCLUDED.title,
+ content = EXCLUDED.content,
+ summary = EXCLUDED.summary,
+ module = EXCLUDED.module,
+ tags = EXCLUDED.tags,
+ difficulty = EXCLUDED.difficulty,
+ updated_at = CURRENT_TIMESTAMP,
+ deleted_at = NULL;
+
+-- 2) Steps: tags i sager
+WITH target AS (
+ SELECT id FROM manual_articles WHERE slug = 'saadan-bruger-du-tags-i-sager' LIMIT 1
+)
+INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
+SELECT
+ target.id,
+ s.step_number,
+ s.title,
+ s.content,
+ s.image_url,
+ s.video_url
+FROM target
+CROSS JOIN (
+ VALUES
+ (1, 'Åbn en sag', 'Gå til sagens detaljeside hvor tags kan redigeres.', NULL, NULL),
+ (2, 'Tilføj relevante tags', 'Tilføj type-tag og evt. brand-tag der matcher problemstillingen.', NULL, NULL),
+ (3, 'Undgå støj', 'Fjern overflødige tags så filtrering forbliver præcis.', NULL, NULL),
+ (4, 'Gem og verificér', 'Gem sagen og test filtrering i sag-listen med de nye tags.', NULL, NULL)
+) AS s(step_number, title, content, image_url, video_url)
+ON CONFLICT (manual_id, step_number) DO UPDATE
+SET
+ title = EXCLUDED.title,
+ content = EXCLUDED.content,
+ image_url = EXCLUDED.image_url,
+ video_url = EXCLUDED.video_url,
+ updated_at = CURRENT_TIMESTAMP;
+
+-- 3) Steps: link mail til sag
+WITH target AS (
+ SELECT id FROM manual_articles WHERE slug = 'saadan-linker-du-mail-til-sag' LIMIT 1
+)
+INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
+SELECT
+ target.id,
+ s.step_number,
+ s.title,
+ s.content,
+ s.image_url,
+ s.video_url
+FROM target
+CROSS JOIN (
+ VALUES
+ (1, 'Find mailtråden', 'Åbn emailmodulet og søg den relevante tråd frem.', NULL, NULL),
+ (2, 'Vælg korrekt sag', 'Match tråden med den sag, der allerede indeholder konteksten.', NULL, NULL),
+ (3, 'Opret link', 'Link mailen til sagen via den relevante handling i UI.', NULL, NULL),
+ (4, 'Bekræft historik', 'Kontrollér at mailaktivitet nu kan ses fra sagen.', NULL, NULL)
+) AS s(step_number, title, content, image_url, video_url)
+ON CONFLICT (manual_id, step_number) DO UPDATE
+SET
+ title = EXCLUDED.title,
+ content = EXCLUDED.content,
+ image_url = EXCLUDED.image_url,
+ video_url = EXCLUDED.video_url,
+ updated_at = CURRENT_TIMESTAMP;
+
+-- 4) Steps: reminders/deferred
+WITH target AS (
+ SELECT id FROM manual_articles WHERE slug = 'reminders-og-deferred-status-i-sag-modulet' LIMIT 1
+)
+INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
+SELECT
+ target.id,
+ s.step_number,
+ s.title,
+ s.content,
+ s.image_url,
+ s.video_url
+FROM target
+CROSS JOIN (
+ VALUES
+ (1, 'Vurder om sagen skal vente', 'Afgør om sagen reelt er blokeret af ekstern handling.', NULL, NULL),
+ (2, 'Sæt deferred/reminder', 'Vælg deferred trigger eller reminder-dato afhængigt af behov.', NULL, NULL),
+ (3, 'Skriv kort begrundelse', 'Notér hvad der ventes på, og hvem der ejer næste skridt.', NULL, NULL),
+ (4, 'Følg op ved trigger', 'Når trigger rammer, genaktiver sagen og fortsæt workflowet.', NULL, NULL)
+) AS s(step_number, title, content, image_url, video_url)
+ON CONFLICT (manual_id, step_number) DO UPDATE
+SET
+ title = EXCLUDED.title,
+ content = EXCLUDED.content,
+ image_url = EXCLUDED.image_url,
+ video_url = EXCLUDED.video_url,
+ updated_at = CURRENT_TIMESTAMP;
+
+-- 5) Context relations
+INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
+SELECT m.id, 'sag', 'tags', 'ticket', NULL
+FROM manual_articles m
+WHERE m.slug = 'saadan-bruger-du-tags-i-sager'
+ AND NOT EXISTS (
+ SELECT 1 FROM manual_relations r
+ WHERE r.manual_id = m.id
+ AND COALESCE(r.related_module, '') = 'sag'
+ AND COALESCE(r.related_tag, '') = 'tags'
+ AND COALESCE(r.related_sag_type, '') = 'ticket'
+ );
+
+INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
+SELECT m.id, 'mail', 'email', NULL, NULL
+FROM manual_articles m
+WHERE m.slug = 'saadan-linker-du-mail-til-sag'
+ AND NOT EXISTS (
+ SELECT 1 FROM manual_relations r
+ WHERE r.manual_id = m.id
+ AND COALESCE(r.related_module, '') = 'mail'
+ AND COALESCE(r.related_tag, '') = 'email'
+ );
+
+INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
+SELECT m.id, 'sag', 'deferred', 'ticket', NULL
+FROM manual_articles m
+WHERE m.slug = 'reminders-og-deferred-status-i-sag-modulet'
+ AND NOT EXISTS (
+ SELECT 1 FROM manual_relations r
+ WHERE r.manual_id = m.id
+ AND COALESCE(r.related_module, '') = 'sag'
+ AND COALESCE(r.related_tag, '') = 'deferred'
+ AND COALESCE(r.related_sag_type, '') = 'ticket'
+ );