From ce49866d2b4034fd5759e8ff233bff0aa471ae05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Wed, 30 Sep 2026 10:34:29 +0200 Subject: [PATCH] Forum: one SEOSoft forum at forum.seosoft.dk, SQLInsight Pro as a category The forum is shared by SEOSoft's products, so SQLInsight Pro becomes the top-level category "sqlinsight" with the existing categories beneath it. - render.py/install.sh: default hostname forum.seosoft.dk - configure.py: forum-wide settings ("SEOSoft Forum") kept apart from the SQLInsight Pro category tree; subcategories found via /site.json; the welcome topic is pinned in the category, not on the shared front page - Community site: ForumCategory option. The front page lists only that category's topics (c//l/latest.json); forum links go to the category, topic links stay at the forum root - Web: default ForumUrl https://forum.seosoft.dk/c/sqlinsight. The old forum.sqlinsight.seosoft.dk stays as a redirect for installed versions Co-Authored-By: Claude Opus 5.5 (1M context) --- .forgejo/workflows/deploy-forum.yml | 2 +- README.md | 2 +- deploy/community/docker-compose.prod.yml | 4 +- deploy/forum/app.yml | 2 +- deploy/forum/configure.py | 111 +++++++++++------- deploy/forum/install.sh | 2 +- deploy/forum/render.py | 4 +- docs/community.md | 19 +-- src/SQLInsight.Community/CommunityOptions.cs | 13 +- .../Components/Layout/SiteLayout.razor | 4 +- .../Components/Pages/Home.razor | 2 +- .../Components/Pages/Support.razor | 2 +- src/SQLInsight.Community/Program.cs | 2 +- .../Services/ForumService.cs | 6 +- src/SQLInsight.Web/WebOptions.cs | 2 +- tests/SQLInsight.Community.Tests/SiteTests.cs | 24 +++- 16 files changed, 134 insertions(+), 67 deletions(-) diff --git a/.forgejo/workflows/deploy-forum.yml b/.forgejo/workflows/deploy-forum.yml index 2bd1ba6..7819d13 100644 --- a/.forgejo/workflows/deploy-forum.yml +++ b/.forgejo/workflows/deploy-forum.yml @@ -1,5 +1,5 @@ # ============================================================================= -# Installér eller genopbyg forummet (Discourse, forum.sqlinsight.seosoft.dk) på appserveren. +# Installér eller genopbyg SEOSofts fælles forum (Discourse, forum.seosoft.dk) på appserveren. SQLInsight Pro er én kategori. # # Kun manuelt (Actions → deploy forum → Run workflow): en rebuild tager 10-15 minutter, og forummet er nede imens. # Discourse opdateres ellers fra /admin/upgrade. diff --git a/README.md b/README.md index e9d627f..f6e418d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ SQLInsight Pro vil gøre det anderledes: | Pris | Gratis | Abonnement | Abonnement | | Al overvågning og analyse | ✅ | ✅ | ✅ | | Opdateringer af værktøj og regelpakker | ✅ | ✅ | ✅ | -| Community support ([forum](https://forum.sqlinsight.seosoft.dk/)) | ✅ | ✅ | ✅ | +| Community support ([forum](https://forum.seosoft.dk/c/sqlinsight)) | ✅ | ✅ | ✅ | | Prioriteret mailsupport med svartidsgaranti | – | ✅ | ✅ | | Telefonsupport | – | – | ✅ | | Findings-sync til SQLInsight Hub (proaktiv opfølgning) | – | Valgfrit | ✅ | diff --git a/deploy/community/docker-compose.prod.yml b/deploy/community/docker-compose.prod.yml index 1632c34..995d04f 100644 --- a/deploy/community/docker-compose.prod.yml +++ b/deploy/community/docker-compose.prod.yml @@ -18,7 +18,9 @@ services: environment: SQLInsight__Community__Feed: /feed SQLInsight__Community__DownloadUrl: https://hub.sqlinsight.seosoft.dk/updates/ - SQLInsight__Community__ForumUrl: https://forum.sqlinsight.seosoft.dk/ + # SEOSofts fælles forum; forsiden viser kun SQLInsight-kategorien. + SQLInsight__Community__ForumUrl: https://forum.seosoft.dk/ + SQLInsight__Community__ForumCategory: sqlinsight volumes: - /opt/sqlinsight-hub/data/feed:/feed:ro ports: diff --git a/deploy/forum/app.yml b/deploy/forum/app.yml index be1393f..9d93079 100644 --- a/deploy/forum/app.yml +++ b/deploy/forum/app.yml @@ -1,4 +1,4 @@ -## SQLInsight Pro community forum (forum.sqlinsight.seosoft.dk): Discourse with the official discourse_docker launcher. +## SEOSoft forum (forum.seosoft.dk), shared by SEOSoft's products (SQLInsight Pro is one category): Discourse with the official discourse_docker launcher. ## See docs/community.md. ## ## This is a template: the double-braced names are filled in by render.py from environment variables (Forgejo secrets in diff --git a/deploy/forum/configure.py b/deploy/forum/configure.py index 6922418..f433afd 100755 --- a/deploy/forum/configure.py +++ b/deploy/forum/configure.py @@ -1,12 +1,15 @@ #!/usr/bin/env python3 -"""Sets up the SQLInsight Pro forum on a fresh Discourse through its API. Safe to run again: it only changes what -differs from the wanted state. +"""Sets up SEOSoft's shared forum and SQLInsight Pro's category in it through the Discourse API. Safe to run again: +it only changes what differs from the wanted state. - DISCOURSE_URL=https://forum.sqlinsight.seosoft.dk DISCOURSE_API_KEY=... python3 deploy/forum/configure.py + DISCOURSE_URL=https://forum.seosoft.dk DISCOURSE_API_KEY=... python3 deploy/forum/configure.py Create the key inside the container: ./launcher enter app then cd /var/www/discourse && su discourse -c 'RAILS_ENV=production bundle exec rake api_key:create_master[forum-setup]' -Optional: COMMUNITY_URL (the community site, linked from the forum), CONTACT_EMAIL. +Optional: COMMUNITY_URL (SQLInsight Pro's site, linked from its category), CONTACT_EMAIL. + +The forum is shared by SEOSoft's products. SQLInsight Pro is one top-level category ("sqlinsight") with its own +subcategories; other products get their own top-level categories and are not touched here. """ import json import os @@ -21,22 +24,26 @@ KEY = os.environ.get("DISCOURSE_API_KEY", "") COMMUNITY = os.environ.get("COMMUNITY_URL", "https://sqlinsight.seosoft.dk").rstrip("/") CONTACT = os.environ.get("CONTACT_EMAIL", "") +# The whole forum (shared by all products). SETTINGS = { - "title": "SQLInsight Pro Forum", - "site_description": "Spørgsmål, svar og erfaringer om SQL Server-overvågning med SQLInsight Pro.", - "short_site_description": "SQL Server-overvågning med dyb ekspertise", + "title": "SEOSoft Forum", + "site_description": "Spørgsmål, svar og erfaringer om SEOSofts produkter.", + "short_site_description": "Community for SEOSofts produkter", "company_name": "SEOSoft", "default_locale": "da", "login_required": "false", "solved_enabled": "true", "tagging_enabled": "true", - "top_menu": "latest|categories|new|top", - "contact_url": f"{COMMUNITY}/support", + "top_menu": "categories|latest|new|top", } if CONTACT: SETTINGS["contact_email"] = CONTACT -# name, slug, colour, description, accepted answers (solved), only staff may start topics +# SQLInsight Pro's top-level category: name, slug, colour, description +PRODUCT = ("SQLInsight Pro", "sqlinsight", "0E7490", + f"Gratis SQL Server-overvågning med dyb ekspertise. Dokumentation og download: {COMMUNITY}") + +# Its subcategories: name, slug, colour, description, accepted answers (solved), only staff may start topics CATEGORIES = [ ("Annonceringer", "annonceringer", "0E7490", "Nye versioner, regelpakker og vigtige SQL Server-opdateringer. Kun SEOSoft opretter emner her; alle kan svare.", False, True), @@ -52,13 +59,13 @@ CATEGORIES = [ "Hvad mangler SQLInsight Pro? Fejl oprettes som issues på Forgejo.", False, False), ] -WELCOME_TITLE = "Velkommen til SQLInsight Pro-forummet" +WELCOME_TITLE = "Velkommen til SQLInsight Pro på SEOSoft Forum" WELCOME = f"""SQLInsight Pro er gratis SQL Server-overvågning med dyb ekspertise: hvert fund har en forklaring, målingerne bag og en konkret anbefaling. **Her kan du** -- stille spørgsmål om installation, fund og tuning i [Spørgsmål og svar](/c/spoergsmaal) -- dele erfaringer og foreslå nye [regler](/c/regler) -- følge nye versioner og regelpakker i [Annonceringer](/c/annonceringer) +- stille spørgsmål om installation, fund og tuning i [Spørgsmål og svar](/c/sqlinsight/spoergsmaal) +- dele erfaringer og foreslå nye [regler](/c/sqlinsight/regler) +- følge nye versioner og regelpakker i [Annonceringer](/c/sqlinsight/annonceringer) **Gode råd til et hurtigt svar** - Skriv SQL Server-version og CU (står på instansens side i SQLInsight Pro). @@ -89,6 +96,41 @@ def call(method, path, data=None): sys.exit(f"{method} {path}: stadig begrænset efter flere forsøg") +def all_categories(): + """Every category including subcategories (/categories.json leaves subcategories out).""" + return call("GET", "/site.json")["categories"] + + +def find(slug, parent_id): + return next((c for c in all_categories() if c["slug"] == slug and c.get("parent_category_id") == parent_id), None) + + +def ensure_category(name, slug, colour, description, parent_id=None, solved=False, staff_only=False): + fields = { + "name": name, "slug": slug, "color": colour, "text_color": "FFFFFF", + "custom_fields[enable_accepted_answers]": "true" if solved else "false", + } + if parent_id is not None: + fields["parent_category_id"] = str(parent_id) + if staff_only: + fields.update({"permissions[everyone]": "2", "permissions[staff]": "1"}) # everyone: reply; staff: create + existing = find(slug, parent_id) + if existing: + call("PUT", f"/categories/{existing['id']}.json", fields) + category_id = existing["id"] + print(f"kategori {name} (opdateret)") + else: + category_id = call("POST", "/categories.json", fields)["category"]["id"] + print(f"kategori {name} (oprettet)") + # The description is the first post of the category's "About" topic. + about = call("GET", f"/c/{category_id}/show.json")["category"].get("topic_url") + if about: + post = call("GET", about + ".json")["post_stream"]["posts"][0] + if post.get("raw") != description and description not in (post.get("cooked") or ""): + call("PUT", f"/posts/{post['id']}.json", {"post[raw]": description}) + return category_id + + def main(): if not BASE or not KEY: sys.exit(__doc__) @@ -101,43 +143,26 @@ def main(): call("PUT", f"/admin/site_settings/{name}.json", {name: value}) print(f"indstilling {name} = {value}") - categories = {c["slug"]: c for c in call("GET", "/categories.json")["category_list"]["categories"]} + name, slug, colour, description = PRODUCT + product_id = ensure_category(name, slug, colour, description) + ids = {} for name, slug, colour, description, solved, staff_only in CATEGORIES: - fields = { - "name": name, "slug": slug, "color": colour, "text_color": "FFFFFF", - "custom_fields[enable_accepted_answers]": "true" if solved else "false", - } - if staff_only: - fields.update({"permissions[everyone]": "2", "permissions[staff]": "1"}) # everyone: reply; staff: create - if slug in categories: - call("PUT", f"/categories/{categories[slug]['id']}.json", fields) - category_id = categories[slug]["id"] - print(f"kategori {name} (opdateret)") - else: - category_id = call("POST", "/categories.json", fields)["category"]["id"] - print(f"kategori {name} (oprettet)") - # The description is the first post of the category's "About" topic. - about = call("GET", f"/c/{category_id}/show.json")["category"].get("topic_url") - if about: - topic = call("GET", about + ".json") - post = topic["post_stream"]["posts"][0] - if post.get("raw") != description and description not in (post.get("cooked") or ""): - call("PUT", f"/posts/{post['id']}.json", {"post[raw]": description}) + ids[slug] = ensure_category(name, slug, colour, description, product_id, solved, staff_only) - category_id = next(c["id"] for c in call("GET", "/categories.json")["category_list"]["categories"] if c["slug"] == "annonceringer") - announcements = call("GET", f"/c/annonceringer/{category_id}/l/latest.json")["topic_list"]["topics"] + announcements = call("GET", f"/c/{ids['annonceringer']}/l/latest.json")["topic_list"]["topics"] if not any(t["title"] == WELCOME_TITLE for t in announcements): - topic = call("POST", "/posts.json", {"title": WELCOME_TITLE, "raw": WELCOME, "category": category_id}) - call("PUT", f"/t/{topic['topic_id']}/status.json", {"status": "pinned_globally", "enabled": "true"}) - print("emne velkomst (oprettet og fastgjort)") - # Discourse's own English welcome topic: no longer pinned or listed; ours replaces it. + topic = call("POST", "/posts.json", {"title": WELCOME_TITLE, "raw": WELCOME, "category": ids["annonceringer"]}) + # Pinned in its category only: the forum's front page belongs to all products. + call("PUT", f"/t/{topic['topic_id']}/status.json", {"status": "pinned", "enabled": "true"}) + print("emne velkomst (oprettet og fastgjort i SQLInsight Pro)") + # Discourse's own English welcome topic: no longer pinned or listed. for topic in call("GET", "/latest.json")["topic_list"]["topics"]: - if topic.get("pinned_globally") and topic["title"] != WELCOME_TITLE and topic["title"].startswith("Welcome to"): + if topic.get("pinned_globally") and topic["title"].startswith("Welcome to"): call("PUT", f"/t/{topic['id']}/status.json", {"status": "pinned_globally", "enabled": "false"}) call("PUT", f"/t/{topic['id']}/status.json", {"status": "visible", "enabled": "false"}) print("emne Discourses engelske velkomst skjult") - feedback = next((c for c in call("GET", "/categories.json")["category_list"]["categories"] if c["slug"] == "site-feedback"), None) + feedback = find("site-feedback", None) if feedback and feedback["name"] != "Om forummet": call("PUT", f"/categories/{feedback['id']}.json", {"name": "Om forummet", "slug": "site-feedback", "color": feedback["color"]}) print("kategori Site Feedback omdøbt til Om forummet") diff --git a/deploy/forum/install.sh b/deploy/forum/install.sh index 6e320d5..92c7455 100755 --- a/deploy/forum/install.sh +++ b/deploy/forum/install.sh @@ -35,7 +35,7 @@ cd "$TARGET" # Skip the interactive prerequisite checks (they cannot be answered from a workflow). ./launcher rebuild app --skip-prereqs -host="${FORUM_HOSTNAME:-forum.sqlinsight.seosoft.dk}" +host="${FORUM_HOSTNAME:-forum.seosoft.dk}" cat < Required: FORUM_BIND KNOWN_PROXY FORUM_ADMIN_EMAIL FORUM_SMTP_ADDRESS FORUM_SMTP_USER FORUM_SMTP_PASSWORD -Optional: FORUM_HOSTNAME (forum.sqlinsight.seosoft.dk), FORUM_PORT (8082), FORUM_SMTP_PORT (587), FORUM_NOTIFICATION_EMAIL (FORUM_SMTP_USER), +Optional: FORUM_HOSTNAME (forum.seosoft.dk), FORUM_PORT (8082), FORUM_SMTP_PORT (587), FORUM_NOTIFICATION_EMAIL (FORUM_SMTP_USER), FORUM_WORKERS (4), FORUM_DB_SHARED_BUFFERS (512MB) """ import ipaddress @@ -13,7 +13,7 @@ import re import sys DEFAULTS = { - "FORUM_HOSTNAME": "forum.sqlinsight.seosoft.dk", + "FORUM_HOSTNAME": "forum.seosoft.dk", "FORUM_PORT": "8082", "FORUM_SMTP_PORT": "587", "FORUM_WORKERS": "4", diff --git a/docs/community.md b/docs/community.md index 2b660b2..75bd4d4 100644 --- a/docs/community.md +++ b/docs/community.md @@ -5,20 +5,23 @@ SQLInsight Pro har to offentlige websteder. Begge drives af SEOSoft: | Websted | Adresse | Hvad | |---|---|---| | Community-webstedet | `https://sqlinsight.seosoft.dk` | Forside, funktioner, download af signerede udgivelser, dokumentation, SQL Server-versioner og supportdatoer, regler og supportaftaler. Projektet `src/SQLInsight.Community`. | -| Forummet | `https://forum.sqlinsight.seosoft.dk` | Discourse på dansk: spørgsmål og svar (med løste svar), installation og drift, performance, regler, idéer og annonceringer. | +| Forummet | `https://forum.seosoft.dk/c/sqlinsight` | SEOSofts fælles Discourse-forum på dansk. SQLInsight Pro er kategorien `sqlinsight` med underkategorier: spørgsmål og svar (med løste svar), installation og drift, performance, regler, idéer og annonceringer. | -Alle tre navne (også `hub.sqlinsight.seosoft.dk`) peger på SEOSofts offentlige IP. Reverse proxyen dér terminerer TLS og sender videre til appserverens DMZ-adresse (`HOST_BIND`), på hver sin port: +Alle navne peger på SEOSofts offentlige IP. Reverse proxyen dér terminerer TLS og sender videre til appserverens DMZ-adresse (`HOST_BIND`), på hver sin port: | Navn | Container | Port (secret) | Deploy | |---|---|---|---| | `hub.sqlinsight.seosoft.dk` | `sqlinsight-hub` | `HOST_PORT` | `deploy-hub.yml`, ved push | | `sqlinsight.seosoft.dk` | `sqlinsight-community` | `COMMUNITY_PORT` | `deploy-community.yml`, ved push | -| `forum.sqlinsight.seosoft.dk` | `app` (Discourse) | `FORUM_PORT` | `deploy-forum.yml`, kun manuelt | +| `forum.seosoft.dk` | `app` (Discourse, fælles for SEOSofts produkter) | `FORUM_PORT` | `deploy-forum.yml`, kun manuelt | +| `forum.sqlinsight.seosoft.dk` | – | – | 301-redirect til `forum.seosoft.dk` (`/` → `/c/sqlinsight`). Ældre installationer linker hertil | Proxyen skal sende `Host`, `X-Forwarded-For` og `X-Forwarded-Proto: https`. For forummet skal den også tillade WebSockets og lange forespørgsler (Discourse bruger long polling på `/message-bus/`, så timeout skal være mindst 60 sekunder). ## Forummet (Discourse) +Forummet er fælles for SEOSofts produkter. Hvert produkt har sin egen hovedkategori; SQLInsight Pro har `sqlinsight`. Webstedet sætter `ForumCategory: sqlinsight`, så forsiden kun viser SQLInsight-emner. + Discourse installeres med den officielle `discourse_docker`-launcher, som er den eneste måde, Discourse understøtter. Workflowet `deploy-forum.yml` kører launcheren på appserveren i en helper-container, med `/var/discourse` og docker.sock fra værten. `deploy/forum/app.yml` er en skabelon, som `render.py` udfylder fra secrets. Intet hemmeligt ligger i repoet. 1. **Krav:** Discourse bruger 2-4 GB RAM og 20 GB disk under `/var/discourse` på appserveren. @@ -35,7 +38,7 @@ Discourse installeres med den officielle `discourse_docker`-launcher, som er den | `FORUM_NOTIFICATION_EMAIL` | `forum@seosoft.dk` | Valgfri, ellers `FORUM_SMTP_USER` | | `FORUM_WORKERS`, `FORUM_DB_SHARED_BUFFERS` | `4`, `512MB` | Valgfri: ca. 2 pr. CPU-kerne og 25 % af den RAM, Discourse må bruge | -4. **Reverse proxy:** Peg `forum.sqlinsight.seosoft.dk` på `http://HOST_BIND:FORUM_PORT`. +4. **Reverse proxy:** Peg `forum.seosoft.dk` på `http://HOST_BIND:FORUM_PORT`. Hostnavnet kan ændres med `FORUM_HOSTNAME`. 5. **Installation:** Kør *Actions → deploy forum → Run workflow*. Første gang tager det 10-15 minutter. Det samme workflow bruges, når `app.yml` eller secrets ændres. Selve Discourse opdateres fra */admin/upgrade*. 6. **Administrator:** Åbn forummet, og opret en bruger med `FORUM_ADMIN_EMAIL`. Den bliver administrator. 7. **Opsætning som kode:** Lav en midlertidig API-nøgle på appserveren: @@ -47,14 +50,14 @@ Discourse installeres med den officielle `discourse_docker`-launcher, som er den Kør derefter: ``` - DISCOURSE_URL=https://forum.sqlinsight.seosoft.dk DISCOURSE_API_KEY=... \ + DISCOURSE_URL=https://forum.seosoft.dk DISCOURSE_API_KEY=... \ CONTACT_EMAIL=support@... python3 deploy/forum/configure.py ``` `configure.py` gør følgende: - - sætter titel, beskrivelser, dansk sprog, tags og løste svar (pluginnet `solved` følger med Discourse); - - opretter kategorierne med beskrivelser. Kun medarbejdere kan skrive i *Annonceringer*; - - opretter og fastgør et velkomstemne; + - sætter forummets titel ("SEOSoft Forum"), beskrivelser, dansk sprog, tags og løste svar (pluginnet `solved` følger med Discourse); + - opretter hovedkategorien *SQLInsight Pro* (`sqlinsight`) og underkategorierne med beskrivelser. Kun medarbejdere kan oprette emner i *Annonceringer*. Andre produkters kategorier røres ikke; + - opretter et velkomstemne og fastgør det i SQLInsight Pro (ikke på hele forummets forside); - skjuler Discourses engelske standardemne. Scriptet kan køres igen. Det ændrer kun det, der afviger. Slet API-nøglen bagefter under *Admin → API*. diff --git a/src/SQLInsight.Community/CommunityOptions.cs b/src/SQLInsight.Community/CommunityOptions.cs index ac62148..ba577e1 100644 --- a/src/SQLInsight.Community/CommunityOptions.cs +++ b/src/SQLInsight.Community/CommunityOptions.cs @@ -13,9 +13,20 @@ public sealed class CommunityOptions /// public Uri? DownloadUrl { get; set; } - /// The Discourse forum, e.g. https://forum.sqlinsight.seosoft.dk/. + /// The Discourse forum's root, e.g. https://forum.seosoft.dk/. Topic links are built from it. public Uri? ForumUrl { get; set; } + /// + /// Slug of SQLInsight Pro's category when the forum is shared with other products (e.g. "sqlinsight"). The front + /// page then only lists that category's topics, and the forum links go to the category instead of the root. + /// + public string? ForumCategory { get; set; } + + /// Where "Forum" links go: the category when is set, otherwise the root. + public Uri? ForumHome => ForumUrl is { } forum && !string.IsNullOrWhiteSpace(ForumCategory) + ? new Uri(forum, $"c/{Uri.EscapeDataString(ForumCategory)}") + : ForumUrl; + public Uri SourceUrl { get; set; } = new("https://git.seosoft.dk/seo/sqlinsight-pro"); /// Where Professional and Enterprise enquiries go. diff --git a/src/SQLInsight.Community/Components/Layout/SiteLayout.razor b/src/SQLInsight.Community/Components/Layout/SiteLayout.razor index 44098a0..b1a8c3c 100644 --- a/src/SQLInsight.Community/Components/Layout/SiteLayout.razor +++ b/src/SQLInsight.Community/Components/Layout/SiteLayout.razor @@ -11,7 +11,7 @@ SQL Server-versioner Regler Support - @if (Options.Value.ForumUrl is { } forum) + @if (Options.Value.ForumHome is { } forum) { Forum } @@ -28,7 +28,7 @@ Gratis og open source (MIT). Udviklet af SEOSoft.