mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
refactor(system-notices): notice dismissals through a Nest provider and repository
This commit is contained in:
@@ -1,4 +1,74 @@
|
||||
import type { UserNoticeDismissals } from '../entities/UserNoticeDismissals.entity';
|
||||
import { toRow } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class UserNoticeDismissalsRepository extends TrekRepository<UserNoticeDismissals> {}
|
||||
/** SN4's projection — `notice_id`/`dismissed_app_version` (the caller already knows which user it asked for). */
|
||||
export interface UserNoticeDismissalRow {
|
||||
notice_id: string;
|
||||
dismissed_app_version: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `user_notice_dismissals` — a genuine TWO-column composite primary key
|
||||
* (`user` relation + `notice_id` text, both `.primary()` — Plan 3f Task 0's
|
||||
* R5/§14 finding, confirmed against the migration DDL:
|
||||
* `Migration20200101014400_system_notices.ts` + the later
|
||||
* `dismissed_app_version` column addition). Every write here goes through
|
||||
* `em.upsert` targeting both columns as the conflict target, never a
|
||||
* synthetic `id`. `user_id` is a `persist(false)` mirror of the `user`
|
||||
* relation — filtered/written through `user` throughout, never the mirror
|
||||
* (the program-wide trap).
|
||||
*/
|
||||
export class UserNoticeDismissalsRepository extends TrekRepository<UserNoticeDismissals> {
|
||||
/**
|
||||
* SN4 — `SELECT notice_id, dismissed_app_version FROM user_notice_dismissals
|
||||
* WHERE user_id = ?`. `SystemNoticesService.getActiveFor` maps the result to
|
||||
* a `Map<noticeId, dismissedAppVersion>` for the per-version re-show logic.
|
||||
*/
|
||||
async listForUser(userId: number): Promise<UserNoticeDismissalRow[]> {
|
||||
const rows = await this.find({ user: userId }, { fields: ['notice_id', 'dismissed_app_version'] });
|
||||
// `dismissed_app_version` is declared optional (`?`) on the entity class
|
||||
// (the `AppSettings`/`AppSettingsRow` precedent — `AppSettings
|
||||
// .repository.ts#findByKeyPrefix`'s own `toRow(...) as ...Row` cast), so
|
||||
// a fields-narrowed `Loaded<...>` types it as possibly-undefined even
|
||||
// though a fields-narrowed read always returns the column (`null`, never
|
||||
// absent) — `toRow` + the row cast bridges that gap.
|
||||
return rows.map((row) => toRow(row) as UserNoticeDismissalRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* SN5 — `dismissNotice`'s `INSERT INTO user_notice_dismissals (user_id,
|
||||
* notice_id, dismissed_at, dismissed_app_version) VALUES (?, ?, ?, ?) ON
|
||||
* CONFLICT(user_id, notice_id) DO UPDATE SET dismissed_at =
|
||||
* excluded.dismissed_at, dismissed_app_version = excluded.dismissed_app_version`.
|
||||
*
|
||||
* Composite-PK `em.upsert` — the shape Task 0 (R5) pinned against the
|
||||
* migration DDL and Task 3's `NotificationChannelPreferencesRepository
|
||||
* .upsertPreference` proved first with a rendered-SQL test
|
||||
* (`NCPREPO-UPSERT-SQL`), re-checked here rather than re-derived
|
||||
* (`UserNoticeDismissals.repository.test.ts`'s `UNDREPO-UPSERT-SQL`).
|
||||
* `onConflictFields` names the two primary-key properties by their ENTITY
|
||||
* property names (`user`, not the `persist(false)` `user_id` mirror);
|
||||
* `onConflictAction: 'merge'` reproduces the `ON CONFLICT ... DO UPDATE`'s
|
||||
* "overwrite `dismissed_at`/`dismissed_app_version`" semantics exactly —
|
||||
* there is no other non-key column for it to diverge on.
|
||||
*
|
||||
* Named `upsertDismissal`, not `upsert`: `TrekRepository` already declares
|
||||
* (and overrides) `upsert` with MikroORM's own generic signature, so a
|
||||
* method of the same name here would have to be a compatible override, not
|
||||
* a differently-shaped convenience wrapper — same reason
|
||||
* `AppSettingsRepository.setValue`/`NotificationChannelPreferencesRepository
|
||||
* .upsertPreference` use their own names rather than `upsert`.
|
||||
*
|
||||
* `dismissedAt` stays the caller's raw epoch-ms integer (`Date.now()`, not
|
||||
* `CURRENT_TIMESTAMP`) — this table is the one place in the whole plan
|
||||
* that stores a timestamp this way; preserved exactly, not normalized to
|
||||
* match every other domain's `CURRENT_TIMESTAMP` convention.
|
||||
*/
|
||||
async upsertDismissal(userId: number, noticeId: string, dismissedAt: number, dismissedAppVersion: string): Promise<void> {
|
||||
await this.upsert(
|
||||
{ user: userId, notice_id: noticeId, dismissed_at: dismissedAt, dismissed_app_version: dismissedAppVersion },
|
||||
{ onConflictFields: ['user', 'notice_id'], onConflictAction: 'merge' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { SystemNoticesController } from './system-notices.controller';
|
||||
import { SystemNoticesService } from './system-notices.service';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { UserNoticeDismissals } from '../../db/entities/UserNoticeDismissals.entity';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
|
||||
/** System-notices domain. Registered in AppModule. */
|
||||
/**
|
||||
* System-notices domain. Registered in AppModule.
|
||||
* `MikroOrmModule.forFeature([Users, Trips, UserNoticeDismissals, AppSettings])`
|
||||
* (Plan 3f Task 6) registers UsersRepository/TripsRepository/
|
||||
* UserNoticeDismissalsRepository/AppSettingsRepository for
|
||||
* `SystemNoticesService`'s `@InjectRepository` constructor params. Only this
|
||||
* module needs the registration — `SystemNoticesService` has no other
|
||||
* constructing module.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AppConfigModule, AddonsModule],
|
||||
imports: [AppConfigModule, AddonsModule, MikroOrmModule.forFeature([Users, Trips, UserNoticeDismissals, AppSettings])],
|
||||
controllers: [SystemNoticesController],
|
||||
providers: [SystemNoticesService],
|
||||
})
|
||||
|
||||
@@ -1,22 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import semver from 'semver';
|
||||
import type { SystemNoticeDto } from '@trek/shared';
|
||||
import { getActiveNoticesFor, dismissNotice, getCurrentAppVersion } from '../../systemNotices/service';
|
||||
import { SYSTEM_NOTICES } from '../../systemNotices/registry';
|
||||
import { evaluate } from '../../systemNotices/conditions';
|
||||
import { getCurrentAppVersion, isNoticeVersionActive, severityWeight } from '../../systemNotices/service';
|
||||
import type { SystemNotice } from '../../systemNotices/types';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { UserNoticeDismissals } from '../../db/entities/UserNoticeDismissals.entity';
|
||||
import { UserNoticeDismissalsRepository } from '../../db/repositories/UserNoticeDismissals.repository';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing system-notices service. The condition
|
||||
* evaluation, version gating, sorting and dismissal persistence all stay in the
|
||||
* upstream service — this only adapts it for DI (and threads the injected
|
||||
* addon-enablement check into the condition context, so the plain modules
|
||||
* underneath carry no bridge import), so behaviour is unchanged.
|
||||
* The setting key(s) any registered `case 'custom'` predicate reads today
|
||||
* (`registry.ts`'s `whitespace-collision-detected`, via
|
||||
* `ctx.settingFlag('whitespace_migration_collision')`) — resolved once per
|
||||
* `getActiveFor` call into a small map, the same shape `addonFlags` below
|
||||
* already uses for `addonEnabled` conditions (Plan 3f Task 0/R1's adopted
|
||||
* wiring). A future predicate reading another `app_settings` key adds its
|
||||
* own entry here; `registry.ts`/`conditions.ts` need no further change
|
||||
* either way.
|
||||
*/
|
||||
const CUSTOM_PREDICATE_SETTING_KEYS = ['whitespace_migration_collision'] as const;
|
||||
|
||||
/**
|
||||
* DI-native `systemNotices` — absorbs the plain `getActiveNoticesFor`/
|
||||
* `dismissNotice` functions that used to live in `../../systemNotices/service.ts`
|
||||
* (Plan 3f Task 6, per Task 0's R1 wiring plan). `isNoticeVersionActive`,
|
||||
* `getCurrentAppVersion` and `severityWeight` are pure (no DB) and stay as
|
||||
* plain exported functions in that file; `evaluate`/`registerPredicate`/
|
||||
* `ConditionContext` stay in `conditions.ts`, `SYSTEM_NOTICES`/
|
||||
* `RETIRED_NOTICE_IDS` in `registry.ts` — this class is the only place that
|
||||
* now reaches a database, through injected repositories instead of the raw
|
||||
* `db` handle.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SystemNoticesService {
|
||||
constructor(
|
||||
private readonly addons: AddonsService,
|
||||
private readonly env: RuntimeEnvService,
|
||||
@InjectRepository(Users) private readonly users: UsersRepository,
|
||||
@InjectRepository(Trips) private readonly trips: TripsRepository,
|
||||
@InjectRepository(UserNoticeDismissals) private readonly dismissals: UserNoticeDismissalsRepository,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -34,11 +66,7 @@ export class SystemNoticesService {
|
||||
* holding it back: a notice is used up by a dismissal and by nothing else.
|
||||
*/
|
||||
async getActiveFor(userId: number, supports: ReadonlySet<string> = new Set(), uiVersion?: string): Promise<SystemNoticeDto[]> {
|
||||
const notices = (await getActiveNoticesFor(
|
||||
userId,
|
||||
(addonId) => this.addons.isAddonEnabled(addonId),
|
||||
this.env.isManaged(),
|
||||
)) as SystemNoticeDto[];
|
||||
const notices = await this.getActiveNoticesFor(userId);
|
||||
return supports.has('release') && this.bundleMatchesServer(uiVersion)
|
||||
? notices
|
||||
: notices.filter(n => !n.release);
|
||||
@@ -52,6 +80,87 @@ export class SystemNoticesService {
|
||||
}
|
||||
|
||||
async dismiss(userId: number, noticeId: string): Promise<boolean> {
|
||||
return dismissNotice(userId, noticeId);
|
||||
const exists = SYSTEM_NOTICES.some(n => n.id === noticeId);
|
||||
if (!exists) return false;
|
||||
// Record the app version at dismissal so per-version notices can re-appear on the
|
||||
// next upgrade. Upsert (not INSERT OR IGNORE) so re-dismissing after a bump
|
||||
// refreshes the version (SN5).
|
||||
await this.dismissals.upsertDismissal(userId, noticeId, Date.now(), getCurrentAppVersion());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The former `getActiveNoticesFor` (SN2/SN3/SN4 reads + the addon-flags
|
||||
* lookup, filter, sort and DTO-mapping) — absorbed verbatim except the
|
||||
* four DB reads now go through injected repositories instead of the raw
|
||||
* `db` handle, and `managed` is read straight off the injected
|
||||
* `RuntimeEnvService` instead of being threaded in as a parameter.
|
||||
*/
|
||||
private async getActiveNoticesFor(userId: number): Promise<SystemNoticeDto[]> {
|
||||
const user = await this.users.findById(userId);
|
||||
if (!user) return [];
|
||||
|
||||
const tripCount = await this.trips.countForUser(userId);
|
||||
|
||||
// Dismissals mapped to the app version they were dismissed at (used by per-version notices).
|
||||
const dismissalRows = await this.dismissals.listForUser(userId);
|
||||
const dismissals = new Map<string, string | null>(dismissalRows.map(r => [r.notice_id, r.dismissed_app_version]));
|
||||
|
||||
const now = new Date();
|
||||
const currentAppVersion = getCurrentAppVersion();
|
||||
|
||||
// `evaluate` runs inside a .filter(), which cannot await, so the addon flags
|
||||
// the registry is able to ask about are resolved up front and handed to it as
|
||||
// a lookup. The ids come from the notices themselves, so every question the
|
||||
// conditions can ask has an answer here.
|
||||
const addonFlags = new Map<string, boolean>();
|
||||
for (const condition of SYSTEM_NOTICES.flatMap(n => n.conditions)) {
|
||||
if (condition.kind === 'addonEnabled' && !addonFlags.has(condition.addonId)) {
|
||||
addonFlags.set(condition.addonId, await this.addons.isAddonEnabled(condition.addonId));
|
||||
}
|
||||
}
|
||||
|
||||
// Same shape as addonFlags above, for the one thing a `case 'custom'`
|
||||
// predicate can currently ask about (SN1).
|
||||
const settingFlags = new Map<string, boolean>();
|
||||
for (const key of CUSTOM_PREDICATE_SETTING_KEYS) {
|
||||
settingFlags.set(key, (await this.appSettings.getValue(key)) === 'true');
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
user: { login_count: user.login_count, first_seen_version: user.first_seen_version, role: user.role, noTrips: tripCount },
|
||||
currentAppVersion,
|
||||
now,
|
||||
addonEnabled: (addonId: string) => addonFlags.get(addonId) ?? false,
|
||||
managed: this.env.isManaged(),
|
||||
settingFlag: (key: string) => settingFlags.get(key) ?? false,
|
||||
};
|
||||
const appVer = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
|
||||
|
||||
const isStillDismissed = (n: SystemNotice): boolean => {
|
||||
if (!dismissals.has(n.id)) return false;
|
||||
if (n.recurring === 'per-version') {
|
||||
// Re-show once the running app version moves past the version it was last dismissed at,
|
||||
// so a per-version notice surfaces again on each install/upgrade.
|
||||
const dismissedVer = semver.coerce(dismissals.get(n.id) ?? '0.0.0')?.version ?? '0.0.0';
|
||||
return semver.gte(dismissedVer, appVer);
|
||||
}
|
||||
return true; // default: permanent one-time dismissal
|
||||
};
|
||||
|
||||
return SYSTEM_NOTICES
|
||||
.filter(n => {
|
||||
if (isStillDismissed(n)) return false;
|
||||
if (!isNoticeVersionActive(n, currentAppVersion)) return false;
|
||||
return evaluate(n, ctx);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const pw = (b.priority ?? 0) - (a.priority ?? 0);
|
||||
if (pw !== 0) return pw;
|
||||
const sw = severityWeight(b.severity) - severityWeight(a.severity);
|
||||
if (sw !== 0) return sw;
|
||||
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
|
||||
})
|
||||
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, recurring: _rc, ...dto }) => dto) as SystemNoticeDto[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ interface ConditionContext {
|
||||
addonEnabled: (addonId: string) => boolean;
|
||||
/** True when somebody other than this install's admin owns its configuration. */
|
||||
managed: boolean;
|
||||
/**
|
||||
* A custom predicate's pre-resolved answer to a question that would
|
||||
* otherwise need a DB read — same shape as `addonEnabled`/`managed`: the
|
||||
* caller (`SystemNoticesService.getActiveFor`, Plan 3f Task 6/R1) resolves
|
||||
* every key a registered `case 'custom'` predicate needs BEFORE calling
|
||||
* `evaluate()`, since `evaluateOne` below calls a predicate synchronously
|
||||
* inside `SYSTEM_NOTICES.filter(...)`, which cannot `await` a repository
|
||||
* read. `registry.ts`'s `whitespace-collision-detected` predicate is the
|
||||
* only current reader (`ctx.settingFlag('whitespace_migration_collision')`);
|
||||
* this module stays free of any DB import either way.
|
||||
*/
|
||||
settingFlag: (key: string) => boolean;
|
||||
}
|
||||
|
||||
// Custom predicate registry — extensible without modifying this file
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import type { SystemNotice } from './types.js';
|
||||
import { registerPredicate } from './conditions.js';
|
||||
import { db } from '../db/database.js';
|
||||
|
||||
registerPredicate('whitespace-collision-detected', () => {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'whitespace_migration_collision'").get() as { value: string } | undefined;
|
||||
return row?.value === 'true';
|
||||
});
|
||||
/**
|
||||
* Registered at module load, unchanged from before this plan (Plan 3f Task
|
||||
* 6/R1's adopted wiring): the closure used to read `app_settings` directly
|
||||
* through the raw `db` handle here; now it only reads a value the caller
|
||||
* (`SystemNoticesService.getActiveFor`) already resolved into `ctx` before
|
||||
* `evaluate()` runs, the same shape `addonEnabled`/`managed` already use.
|
||||
* `evaluateOne`'s `case 'custom'` (`conditions.ts`) calls this synchronously
|
||||
* inside a `.filter()`, which cannot `await` — moving the DB read into this
|
||||
* closure directly would either need to make the closure (and the whole
|
||||
* evaluate/evaluateOne chain) async, or read an un-awaited repository
|
||||
* result. Reading a pre-resolved value off `ctx` avoids both, stays
|
||||
* race-free (the value flows through the per-call `ctx` object, not shared
|
||||
* mutable state on a singleton), and needs no Nest lifecycle hook — this
|
||||
* registration stays exactly where it always was.
|
||||
*/
|
||||
registerPredicate('whitespace-collision-detected', (ctx) => ctx.settingFlag('whitespace_migration_collision'));
|
||||
|
||||
/**
|
||||
* SYSTEM NOTICE REGISTRY
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import semver from 'semver';
|
||||
import { readEnv } from '../app-config';
|
||||
import { db } from '../db/database.js';
|
||||
import { SYSTEM_NOTICES } from './registry.js';
|
||||
import { evaluate } from './conditions.js';
|
||||
import type { SystemNotice, SystemNoticeDTO } from './types.js';
|
||||
import type { SystemNotice } from './types.js';
|
||||
|
||||
export function getCurrentAppVersion(): string {
|
||||
const fromEnv = semver.valid(readEnv().app.appVersion ?? '');
|
||||
@@ -31,91 +28,14 @@ export function isNoticeVersionActive(n: SystemNotice, currentAppVersion: string
|
||||
return true;
|
||||
}
|
||||
|
||||
function severityWeight(s: string): number {
|
||||
/**
|
||||
* Sort weight: critical > warn > info. Pure, no DB — kept here per Plan 3f
|
||||
* Task 0's R1 wiring plan ("isNoticeVersionActive, getCurrentAppVersion,
|
||||
* severityWeight are pure — stay as plain exported functions"), now exported
|
||||
* for `SystemNoticesService.getActiveFor` (`nest/system-notices/`), which
|
||||
* absorbed the DB-touching `getActiveNoticesFor`/`dismissNotice` that used to
|
||||
* live in this file.
|
||||
*/
|
||||
export function severityWeight(s: string): number {
|
||||
return s === 'critical' ? 2 : s === 'warn' ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function getActiveNoticesFor(
|
||||
userId: number,
|
||||
addonEnabled: (addonId: string) => Promise<boolean>,
|
||||
managed = false
|
||||
): Promise<SystemNoticeDTO[]> {
|
||||
const user = db.prepare(
|
||||
'SELECT login_count, first_seen_version, role FROM users WHERE id = ?'
|
||||
).get(userId) as { login_count: number; first_seen_version: string; role: string } | undefined;
|
||||
|
||||
if (!user) return [];
|
||||
|
||||
const { count: tripCount } = db.prepare(
|
||||
'SELECT COUNT(*) AS count FROM trips WHERE user_id = ?'
|
||||
).get(userId) as { count: number };
|
||||
|
||||
// Dismissals mapped to the app version they were dismissed at (used by per-version notices).
|
||||
const dismissals = new Map<string, string | null>(
|
||||
(db.prepare('SELECT notice_id, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ?')
|
||||
.all(userId) as Array<{ notice_id: string; dismissed_app_version: string | null }>)
|
||||
.map(r => [r.notice_id, r.dismissed_app_version])
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const currentAppVersion = getCurrentAppVersion();
|
||||
// `evaluate` runs inside a .filter(), which cannot await, so the addon flags
|
||||
// the registry is able to ask about are resolved up front and handed to it as
|
||||
// a lookup. The ids come from the notices themselves, so every question the
|
||||
// conditions can ask has an answer here.
|
||||
const addonFlags = new Map<string, boolean>();
|
||||
for (const condition of SYSTEM_NOTICES.flatMap(n => n.conditions)) {
|
||||
if (condition.kind === 'addonEnabled' && !addonFlags.has(condition.addonId)) {
|
||||
addonFlags.set(condition.addonId, await addonEnabled(condition.addonId));
|
||||
}
|
||||
}
|
||||
const ctx = {
|
||||
user: { ...user, noTrips: tripCount },
|
||||
currentAppVersion,
|
||||
now,
|
||||
addonEnabled: (addonId: string) => addonFlags.get(addonId),
|
||||
managed,
|
||||
};
|
||||
const appVer = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
|
||||
|
||||
const isStillDismissed = (n: SystemNotice): boolean => {
|
||||
if (!dismissals.has(n.id)) return false;
|
||||
if (n.recurring === 'per-version') {
|
||||
// Re-show once the running app version moves past the version it was last dismissed at,
|
||||
// so a per-version notice surfaces again on each install/upgrade.
|
||||
const dismissedVer = semver.coerce(dismissals.get(n.id) ?? '0.0.0')?.version ?? '0.0.0';
|
||||
return semver.gte(dismissedVer, appVer);
|
||||
}
|
||||
return true; // default: permanent one-time dismissal
|
||||
};
|
||||
|
||||
return SYSTEM_NOTICES
|
||||
.filter(n => {
|
||||
if (isStillDismissed(n)) return false;
|
||||
if (!isNoticeVersionActive(n, currentAppVersion)) return false;
|
||||
return evaluate(n, ctx);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const pw = (b.priority ?? 0) - (a.priority ?? 0);
|
||||
if (pw !== 0) return pw;
|
||||
const sw = severityWeight(b.severity) - severityWeight(a.severity);
|
||||
if (sw !== 0) return sw;
|
||||
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
|
||||
})
|
||||
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, recurring: _rc, ...dto }) => dto);
|
||||
}
|
||||
|
||||
export async function dismissNotice(userId: number, noticeId: string): Promise<boolean> {
|
||||
const exists = SYSTEM_NOTICES.some(n => n.id === noticeId);
|
||||
if (!exists) return false;
|
||||
// Record the app version at dismissal so per-version notices can re-appear on the next
|
||||
// upgrade. Upsert (not INSERT OR IGNORE) so re-dismissing after a bump refreshes the version.
|
||||
db.prepare(`
|
||||
INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at, dismissed_app_version)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, notice_id) DO UPDATE SET
|
||||
dismissed_at = excluded.dismissed_at,
|
||||
dismissed_app_version = excluded.dismissed_app_version
|
||||
`).run(userId, noticeId, Date.now(), getCurrentAppVersion());
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user