From f39a762072c56bb231148398d2d1928ffcf6ff09 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 9 Sep 2023 17:20:18 +0200 Subject: [PATCH] add global notices --- backend/db/notice.go | 66 ++++++++++++++++++++++++ backend/db/user_settings.go | 1 + backend/routes/v1/meta/meta.go | 31 +++++++++-- backend/routes/v1/mod/notices.go | 55 ++++++++++++++++++++ backend/routes/v1/mod/routes.go | 2 + backend/routes/v2/user/patch_settings.go | 4 ++ frontend/src/lib/api/entities.ts | 1 + frontend/src/lib/api/responses.ts | 1 + frontend/src/lib/utils.ts | 10 ++++ frontend/src/routes/+layout.server.ts | 3 +- frontend/src/routes/+layout.svelte | 26 +++++++++- scripts/migrate/022_notices.sql | 14 +++++ 12 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 backend/db/notice.go create mode 100644 backend/routes/v1/mod/notices.go create mode 100644 scripts/migrate/022_notices.sql diff --git a/backend/db/notice.go b/backend/db/notice.go new file mode 100644 index 0000000..3f77b8f --- /dev/null +++ b/backend/db/notice.go @@ -0,0 +1,66 @@ +package db + +import ( + "context" + "time" + + "emperror.dev/errors" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" +) + +type Notice struct { + ID int + Notice string + StartTime time.Time + EndTime time.Time +} + +func (db *DB) Notices(ctx context.Context) (ns []Notice, err error) { + sql, args, err := sq.Select("*").From("notices").OrderBy("id DESC").ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &ns, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return NotNull(ns), nil +} + +func (db *DB) CreateNotice(ctx context.Context, notice string, start, end time.Time) (n Notice, err error) { + sql, args, err := sq.Insert("notices").SetMap(map[string]any{ + "notice": notice, + "start_time": start, + "end_time": end, + }).Suffix("RETURNING *").ToSql() + if err != nil { + return n, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &n, sql, args...) + if err != nil { + return n, errors.Wrap(err, "executing query") + } + return n, nil +} + +const ErrNoNotice = errors.Sentinel("no current notice") + +func (db *DB) CurrentNotice(ctx context.Context) (n Notice, err error) { + sql, args, err := sq.Select("*").From("notices").Where("end_time > ?", time.Now()).OrderBy("id DESC").Limit(1).ToSql() + if err != nil { + return n, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &n, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return n, ErrNoNotice + } + + return n, errors.Wrap(err, "executing query") + } + return n, nil +} diff --git a/backend/db/user_settings.go b/backend/db/user_settings.go index 7411821..719573a 100644 --- a/backend/db/user_settings.go +++ b/backend/db/user_settings.go @@ -10,6 +10,7 @@ import ( type UserSettings struct { ReadChangelog string `json:"read_changelog"` ReadSettingsNotice string `json:"read_settings_notice"` + ReadGlobalNotice int `json:"read_global_notice"` } func (db *DB) UpdateUserSettings(ctx context.Context, id xid.ID, us UserSettings) error { diff --git a/backend/routes/v1/meta/meta.go b/backend/routes/v1/meta/meta.go index ebae428..66b6455 100644 --- a/backend/routes/v1/meta/meta.go +++ b/backend/routes/v1/meta/meta.go @@ -4,6 +4,8 @@ import ( "net/http" "os" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" "github.com/go-chi/chi/v5" "github.com/go-chi/render" @@ -20,11 +22,17 @@ func Mount(srv *server.Server, r chi.Router) { } type MetaResponse struct { - GitRepository string `json:"git_repository"` - GitCommit string `json:"git_commit"` - Users MetaUsers `json:"users"` - Members int64 `json:"members"` - RequireInvite bool `json:"require_invite"` + GitRepository string `json:"git_repository"` + GitCommit string `json:"git_commit"` + Users MetaUsers `json:"users"` + Members int64 `json:"members"` + RequireInvite bool `json:"require_invite"` + Notice *MetaNotice `json:"notice"` +} + +type MetaNotice struct { + ID int `json:"id"` + Notice string `json:"notice"` } type MetaUsers struct { @@ -39,6 +47,18 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error { numUsers, numMembers, activeDay, activeWeek, activeMonth := s.DB.Counts(ctx) + var notice *MetaNotice + if n, err := s.DB.CurrentNotice(ctx); err != nil { + if err == db.ErrNoNotice { + log.Errorf("getting notice: %v", err) + } + } else { + notice = &MetaNotice{ + ID: n.ID, + Notice: n.Notice, + } + } + render.JSON(w, r, MetaResponse{ GitRepository: server.Repository, GitCommit: server.Revision, @@ -50,6 +70,7 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error { }, Members: numMembers, RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", + Notice: notice, }) return nil } diff --git a/backend/routes/v1/mod/notices.go b/backend/routes/v1/mod/notices.go new file mode 100644 index 0000000..93c9b26 --- /dev/null +++ b/backend/routes/v1/mod/notices.go @@ -0,0 +1,55 @@ +package mod + +import ( + "net/http" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/aarondl/opt/omit" + "github.com/go-chi/render" +) + +type createNoticeRequest struct { + Notice string `json:"notice"` + Start omit.Val[time.Time] `json:"start"` + End time.Time `json:"end"` +} + +type noticeResponse struct { + ID int `json:"id"` + Notice string `json:"notice"` + StartTime time.Time `json:"start"` + EndTime time.Time `json:"end"` +} + +func (s *Server) createNotice(w http.ResponseWriter, r *http.Request) error { + var req createNoticeRequest + err := render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if common.StringLength(&req.Notice) > 2000 { + return server.APIError{Code: server.ErrBadRequest, Details: "Notice is too long, max 2000 characters"} + } + + start := req.Start.GetOr(time.Now()) + if req.End.IsZero() { + return server.APIError{Code: server.ErrBadRequest, Details: "`end` is missing or invalid"} + } + + n, err := s.DB.CreateNotice(r.Context(), req.Notice, start, req.End) + if err != nil { + return errors.Wrap(err, "creating notice") + } + + render.JSON(w, r, noticeResponse{ + ID: n.ID, + Notice: n.Notice, + StartTime: n.StartTime, + EndTime: n.EndTime, + }) + return nil +} diff --git a/backend/routes/v1/mod/routes.go b/backend/routes/v1/mod/routes.go index aaed170..fefda48 100644 --- a/backend/routes/v1/mod/routes.go +++ b/backend/routes/v1/mod/routes.go @@ -22,6 +22,8 @@ func Mount(srv *server.Server, r chi.Router) { r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter)) r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport)) + + r.Post("/notices", server.WrapHandler(s.createNotice)) }) r.With(MustAdmin).Handle("/metrics", promhttp.Handler()) diff --git a/backend/routes/v2/user/patch_settings.go b/backend/routes/v2/user/patch_settings.go index 777fe09..90d2ca2 100644 --- a/backend/routes/v2/user/patch_settings.go +++ b/backend/routes/v2/user/patch_settings.go @@ -12,6 +12,7 @@ import ( type PatchSettingsRequest struct { ReadChangelog omitnull.Val[string] `json:"read_changelog"` ReadSettingsNotice omitnull.Val[string] `json:"read_settings_notice"` + ReadGlobalNotice omitnull.Val[int] `json:"read_global_notice"` } func (s *Server) PatchSettings(w http.ResponseWriter, r *http.Request) (err error) { @@ -34,6 +35,9 @@ func (s *Server) PatchSettings(w http.ResponseWriter, r *http.Request) (err erro if !req.ReadSettingsNotice.IsUnset() { u.Settings.ReadSettingsNotice = req.ReadSettingsNotice.GetOrZero() } + if !req.ReadGlobalNotice.IsUnset() { + u.Settings.ReadGlobalNotice = req.ReadGlobalNotice.GetOrZero() + } err = s.DB.UpdateUserSettings(ctx, u.ID, u.Settings) if err != nil { diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 79b285a..8129ef7 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -65,6 +65,7 @@ export interface MeUser extends User { export interface Settings { read_changelog: string; read_settings_notice: string; + read_global_notice: number; } export interface Field { diff --git a/frontend/src/lib/api/responses.ts b/frontend/src/lib/api/responses.ts index 5a343f8..7730b6e 100644 --- a/frontend/src/lib/api/responses.ts +++ b/frontend/src/lib/api/responses.ts @@ -11,6 +11,7 @@ export interface MetaResponse { users: MetaUsers; members: number; require_invite: boolean; + notice: { id: number; notice: string } | null; } export interface MetaUsers { diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 276d98a..68fd1de 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -7,8 +7,18 @@ const md = new MarkdownIt({ linkify: true, }).disable(["heading", "lheading", "link", "table", "blockquote"]); +const unsafeMd = new MarkdownIt({ + html: false, + breaks: true, + linkify: true, +}); + export function renderMarkdown(src: string | null) { return src ? sanitize(md.render(src)) : null; } +export function renderUnsafeMarkdown(src: string) { + return sanitize(unsafeMd.render(src)); +} + export const charCount = (str: string) => [...str].length; diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts index 1af3ac9..baacda0 100644 --- a/frontend/src/routes/+layout.server.ts +++ b/frontend/src/routes/+layout.server.ts @@ -22,7 +22,8 @@ export const load = (async () => { }, members: 0, require_invite: false, - }; + notice: null, + } as MetaResponse; } else { throw e; } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index e3040c1..ed696f8 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -10,9 +10,13 @@ import Navigation from "./nav/Navigation.svelte"; import type { LayoutData } from "./$types"; import { version } from "$app/environment"; + import { settingsStore } from "$lib/store"; import { toastStore } from "$lib/toast"; import Toast from "$lib/components/Toast.svelte"; - import { Icon } from "sveltestrap"; + import { Alert, Icon } from "sveltestrap"; + import { apiFetchClient } from "$lib/api/fetch"; + import type { Settings } from "$lib/api/entities"; + import { renderUnsafeMarkdown } from "$lib/utils"; export let data: LayoutData; @@ -21,6 +25,20 @@ if (versionParts.length >= 3) commit = versionParts[2].slice(1); const versionMismatch = data.git_commit !== commit && data.git_commit !== "[unknown]"; + + const readNotice = async () => { + try { + const resp = await apiFetchClient( + "/users/@me/settings", + "PATCH", + { read_global_notice: data.notice!.id }, + 2, + ); + settingsStore.set({ current: true, settings: resp }); + } catch (e) { + console.log("updating settings:", e); + } + }; @@ -34,6 +52,12 @@
+ {#if data.notice && $settingsStore.current && data.notice.id > $settingsStore.settings.read_global_notice} + readNotice()}> + {@html renderUnsafeMarkdown(data.notice.notice)} + + {/if} +
{#each $toastStore as toast} diff --git a/scripts/migrate/022_notices.sql b/scripts/migrate/022_notices.sql new file mode 100644 index 0000000..c73097d --- /dev/null +++ b/scripts/migrate/022_notices.sql @@ -0,0 +1,14 @@ +-- 2023-09-09: Add global notices + +-- +migrate Up + +create table notices ( + id serial primary key, + notice text not null, + start_time timestamptz not null default now(), + end_time timestamptz not null +); + +-- +migrate Down + +drop table notices;