enhancement(web): modernize APIClient and improve robustness (#1093)

modernized APIClient, removed Notification type collision

enhancement: APIClient now follows the recent RFC3986 (this wasn't the case previously)
enhancement: improved APIClient DX by adding a queryString parameter (avoiding URLSearchParameters)
fix: changed Notification type to ServiceNotification (collision with built-in browser API https://developer.mozilla.org/en-US/docs/Web/API/Notification -- so TS checks wouldn't function as necessary)
This commit is contained in:
stacksmash76 2023-09-10 16:18:39 +02:00 committed by GitHub
parent 2fed48e0dd
commit 438902137b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 244 additions and 123 deletions

View file

@ -7,132 +7,228 @@ import { baseUrl, sseBaseUrl } from "@utils";
import { AuthContext } from "@utils/Context"; import { AuthContext } from "@utils/Context";
import { GithubRelease } from "@app/types/Update"; import { GithubRelease } from "@app/types/Update";
interface ConfigType { type RequestBody = BodyInit | object | Record<string, unknown> | null;
body?: BodyInit | Record<string, unknown> | unknown; type Primitive = string | number | boolean | symbol | undefined;
headers?: Record<string, string>;
interface HttpConfig {
method?: string;
body?: RequestBody;
queryString?: Record<string, Primitive | Primitive[]>;
} }
type PostBody = BodyInit | Record<string, unknown> | unknown; // See https://stackoverflow.com/a/62969380
function encodeRFC3986URIComponent(str: string): string {
return encodeURIComponent(str).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
export async function HttpClient<T>( export async function HttpClient<T = unknown>(
endpoint: string, endpoint: string,
method: string, config: HttpConfig = {}
{ body, ...customConfig }: ConfigType = {}
): Promise<T> { ): Promise<T> {
const config = { const init: RequestInit = {
method: method, method: config.method,
body: body ? JSON.stringify(body) : undefined, headers: { "Accept": "*/*" }
headers: { };
"Content-Type": "application/json"
},
// NOTE: customConfig can override the above defined settings
...customConfig
} as RequestInit;
return window.fetch(`${baseUrl()}${endpoint}`, config) if (config.body) {
.then(async response => { init.body = JSON.stringify(config.body);
if (!response.ok) {
// if 401 consider the session expired and force logout
if (response.status === 401) {
// Remove auth info from localStorage
AuthContext.reset();
// Show an error toast to notify the user what occurred if (typeof(config.body) === "object") {
return Promise.reject(new Error("Unauthorized")); init.headers = {
} else if (response.status === 404) { ...init.headers,
return Promise.reject(new Error("Not found")); "Content-Type": "application/json"
};
}
}
if (config.queryString) {
const params: string[] = [];
for (const [key, value] of Object.entries(config.queryString)) {
const serializedKey = encodeRFC3986URIComponent(key);
if (typeof(value) === undefined) {
// Skip case when the value is undefined.
// The solution in this case is to use the request body instead with JSON
continue;
} else if (Array.isArray(value)) {
// Append (don't set) each array member as a query parameter
// e.g. ?a=1&a=2&a=3
value.forEach((child) => {
// Skip undefined member values
const v = typeof(child) !== undefined ? String(child) : "";
if (v.length) {
params.push(`${serializedKey}=${encodeRFC3986URIComponent(v)}`);
}
});
} else {
// This is a primitive value, just add as string
// e.g. ?a=1
const v = String(value);
if (v.length) {
params.push(`${serializedKey}=${encodeRFC3986URIComponent(v)}`);
} }
return Promise.reject(new Error(await response.text()));
} }
}
// Resolve immediately since 204 contains no data if (params.length) {
if (response.status === 204) endpoint += `?${params.join("&")}`;
return Promise.resolve(response); }
}
return await response.json(); const response = await window.fetch(`${baseUrl()}${endpoint}`, init);
});
switch (response.status) {
case 204:
// 204 contains no data, but indicates success
return Promise.resolve<T>({} as T);
case 401:
// Remove auth info from localStorage
AuthContext.reset();
// Show an error toast to notify the user what occurred
return Promise.reject(new Error(`[401] Unauthorized: "${endpoint}"`));
case 404:
return Promise.reject(new Error(`[404] Not found: "${endpoint}"`));
case 500:
const health = await window.fetch(`${baseUrl()}api/healthz/liveness`);
if (!health.ok) {
return Promise.reject(
new Error(`[500] Offline (Internal server error): "${endpoint}"`, { cause: "OFFLINE" })
);
}
break;
default:
break;
}
const isJson = response.headers.get("Content-Type")?.includes("application/json");
const json = isJson ? await response.json() : null;
// Resolve on success
if (response.status >= 200 && response.status < 300) {
if (json) {
return Promise.resolve<T>(json as T);
} else {
return Promise.resolve<T>(response as T);
}
}
// Otherwise reject, this is most likely an error
return Promise.reject<T>(json as T);
} }
const appClient = { const appClient = {
Get: <T>(endpoint: string) => HttpClient<T>(endpoint, "GET"), Get: <T>(endpoint: string, config: HttpConfig = {}) => HttpClient<T>(endpoint, {
Post: <T = void>(endpoint: string, data: PostBody = undefined) => HttpClient<T>(endpoint, "POST", { body: data }), ...config,
Put: <T = void>(endpoint: string, data: PostBody) => HttpClient<T>(endpoint, "PUT", { body: data }), method: "GET"
Patch: (endpoint: string, data: PostBody = undefined) => HttpClient<void>(endpoint, "PATCH", { body: data }), }),
Delete: (endpoint: string) => HttpClient<void>(endpoint, "DELETE") Post: <T = void>(endpoint: string, config: HttpConfig = {}) => HttpClient<T>(endpoint, {
...config,
method: "POST"
}),
Put: <T = void>(endpoint: string, config: HttpConfig = {}) => HttpClient<T>(endpoint, {
...config,
method: "PUT"
}),
Patch: (endpoint: string, config: HttpConfig = {}) => HttpClient<void>(endpoint, {
...config,
method: "PATCH"
}),
Delete: (endpoint: string, config: HttpConfig = {}) => HttpClient<void>(endpoint, {
...config,
method: "DELETE"
})
}; };
export const APIClient = { export const APIClient = {
auth: { auth: {
login: (username: string, password: string) => appClient.Post("api/auth/login", { login: (username: string, password: string) => appClient.Post("api/auth/login", {
username: username, body: { username, password }
password: password
}), }),
logout: () => appClient.Post("api/auth/logout"), logout: () => appClient.Post("api/auth/logout"),
validate: () => appClient.Get<void>("api/auth/validate"), validate: () => appClient.Get<void>("api/auth/validate"),
onboard: (username: string, password: string) => appClient.Post("api/auth/onboard", { onboard: (username: string, password: string) => appClient.Post("api/auth/onboard", {
username: username, body: { username, password }
password: password
}), }),
canOnboard: () => appClient.Get("api/auth/onboard") canOnboard: () => appClient.Get("api/auth/onboard")
}, },
actions: { actions: {
create: (action: Action) => appClient.Post("api/actions", action), create: (action: Action) => appClient.Post("api/actions", {
update: (action: Action) => appClient.Put(`api/actions/${action.id}`, action), body: action
}),
update: (action: Action) => appClient.Put(`api/actions/${action.id}`, {
body: action
}),
delete: (id: number) => appClient.Delete(`api/actions/${id}`), delete: (id: number) => appClient.Delete(`api/actions/${id}`),
toggleEnable: (id: number) => appClient.Patch(`api/actions/${id}/toggleEnabled`) toggleEnable: (id: number) => appClient.Patch(`api/actions/${id}/toggleEnabled`)
}, },
apikeys: { apikeys: {
getAll: () => appClient.Get<APIKey[]>("api/keys"), getAll: () => appClient.Get<APIKey[]>("api/keys"),
create: (key: APIKey) => appClient.Post("api/keys", key), create: (key: APIKey) => appClient.Post("api/keys", {
body: key
}),
delete: (key: string) => appClient.Delete(`api/keys/${key}`) delete: (key: string) => appClient.Delete(`api/keys/${key}`)
}, },
config: { config: {
get: () => appClient.Get<Config>("api/config"), get: () => appClient.Get<Config>("api/config"),
update: (config: ConfigUpdate) => appClient.Patch("api/config", config) update: (config: ConfigUpdate) => appClient.Patch("api/config", {
body: config
})
}, },
download_clients: { download_clients: {
getAll: () => appClient.Get<DownloadClient[]>("api/download_clients"), getAll: () => appClient.Get<DownloadClient[]>("api/download_clients"),
create: (dc: DownloadClient) => appClient.Post("api/download_clients", dc), create: (dc: DownloadClient) => appClient.Post("api/download_clients", {
update: (dc: DownloadClient) => appClient.Put("api/download_clients", dc), body: dc
}),
update: (dc: DownloadClient) => appClient.Put("api/download_clients", {
body: dc
}),
delete: (id: number) => appClient.Delete(`api/download_clients/${id}`), delete: (id: number) => appClient.Delete(`api/download_clients/${id}`),
test: (dc: DownloadClient) => appClient.Post("api/download_clients/test", dc) test: (dc: DownloadClient) => appClient.Post("api/download_clients/test", {
body: dc
})
}, },
filters: { filters: {
getAll: () => appClient.Get<Filter[]>("api/filters"), getAll: () => appClient.Get<Filter[]>("api/filters"),
find: (indexers: string[], sortOrder: string) => { find: (indexers: string[], sortOrder: string) => appClient.Get<Filter[]>("api/filters", {
const params = new URLSearchParams(); queryString: {
sort: sortOrder,
if (sortOrder.length > 0) { indexer: indexers
params.append("sort", sortOrder);
} }
}),
indexers?.forEach((i) => {
if (i !== undefined || i !== "") {
params.append("indexer", i);
}
});
const p = params.toString();
const q = p ? `?${p}` : "";
return appClient.Get<Filter[]>(`api/filters${q}`);
},
getByID: (id: number) => appClient.Get<Filter>(`api/filters/${id}`), getByID: (id: number) => appClient.Get<Filter>(`api/filters/${id}`),
create: (filter: Filter) => appClient.Post<Filter>("api/filters", filter), create: (filter: Filter) => appClient.Post<Filter>("api/filters", {
update: (filter: Filter) => appClient.Put<Filter>(`api/filters/${filter.id}`, filter), body: filter
}),
update: (filter: Filter) => appClient.Put<Filter>(`api/filters/${filter.id}`, {
body: filter
}),
duplicate: (id: number) => appClient.Get<Filter>(`api/filters/${id}/duplicate`), duplicate: (id: number) => appClient.Get<Filter>(`api/filters/${id}/duplicate`),
toggleEnable: (id: number, enabled: boolean) => appClient.Put(`api/filters/${id}/enabled`, { enabled }), toggleEnable: (id: number, enabled: boolean) => appClient.Put(`api/filters/${id}/enabled`, {
body: { enabled }
}),
delete: (id: number) => appClient.Delete(`api/filters/${id}`) delete: (id: number) => appClient.Delete(`api/filters/${id}`)
}, },
feeds: { feeds: {
find: () => appClient.Get<Feed[]>("api/feeds"), find: () => appClient.Get<Feed[]>("api/feeds"),
create: (feed: FeedCreate) => appClient.Post("api/feeds", feed), create: (feed: FeedCreate) => appClient.Post("api/feeds", {
toggleEnable: (id: number, enabled: boolean) => appClient.Patch(`api/feeds/${id}/enabled`, { enabled }), body: feed
update: (feed: Feed) => appClient.Put(`api/feeds/${feed.id}`, feed), }),
toggleEnable: (id: number, enabled: boolean) => appClient.Patch(`api/feeds/${id}/enabled`, {
body: { enabled }
}),
update: (feed: Feed) => appClient.Put(`api/feeds/${feed.id}`, {
body: feed
}),
delete: (id: number) => appClient.Delete(`api/feeds/${id}`), delete: (id: number) => appClient.Delete(`api/feeds/${id}`),
deleteCache: (id: number) => appClient.Delete(`api/feeds/${id}/cache`), deleteCache: (id: number) => appClient.Delete(`api/feeds/${id}/cache`),
test: (feed: Feed) => appClient.Post("api/feeds/test", feed) test: (feed: Feed) => appClient.Post("api/feeds/test", {
body: feed
})
}, },
indexers: { indexers: {
// returns indexer options for all currently present/enabled indexers // returns indexer options for all currently present/enabled indexers
@ -141,19 +237,34 @@ export const APIClient = {
getAll: () => appClient.Get<IndexerDefinition[]>("api/indexer"), getAll: () => appClient.Get<IndexerDefinition[]>("api/indexer"),
// returns all possible indexer definitions // returns all possible indexer definitions
getSchema: () => appClient.Get<IndexerDefinition[]>("api/indexer/schema"), getSchema: () => appClient.Get<IndexerDefinition[]>("api/indexer/schema"),
create: (indexer: Indexer) => appClient.Post<Indexer>("api/indexer", indexer), create: (indexer: Indexer) => appClient.Post<Indexer>("api/indexer", {
update: (indexer: Indexer) => appClient.Put("api/indexer", indexer), body: indexer
}),
update: (indexer: Indexer) => appClient.Put("api/indexer", {
body: indexer
}),
delete: (id: number) => appClient.Delete(`api/indexer/${id}`), delete: (id: number) => appClient.Delete(`api/indexer/${id}`),
testApi: (req: IndexerTestApiReq) => appClient.Post<IndexerTestApiReq>(`api/indexer/${req.id}/api/test`, req) testApi: (req: IndexerTestApiReq) => appClient.Post<IndexerTestApiReq>(`api/indexer/${req.id}/api/test`, {
body: req
})
}, },
irc: { irc: {
getNetworks: () => appClient.Get<IrcNetworkWithHealth[]>("api/irc"), getNetworks: () => appClient.Get<IrcNetworkWithHealth[]>("api/irc"),
createNetwork: (network: IrcNetworkCreate) => appClient.Post("api/irc", network), createNetwork: (network: IrcNetworkCreate) => appClient.Post("api/irc", {
updateNetwork: (network: IrcNetwork) => appClient.Put(`api/irc/network/${network.id}`, network), body: network
}),
updateNetwork: (network: IrcNetwork) => appClient.Put(`api/irc/network/${network.id}`, {
body: network
}),
deleteNetwork: (id: number) => appClient.Delete(`api/irc/network/${id}`), deleteNetwork: (id: number) => appClient.Delete(`api/irc/network/${id}`),
restartNetwork: (id: number) => appClient.Get(`api/irc/network/${id}/restart`), restartNetwork: (id: number) => appClient.Get(`api/irc/network/${id}/restart`),
sendCmd: (cmd: SendIrcCmdRequest) => appClient.Post(`api/irc/network/${cmd.network_id}/cmd`, cmd), sendCmd: (cmd: SendIrcCmdRequest) => appClient.Post(`api/irc/network/${cmd.network_id}/cmd`, {
events: (network: string) => new EventSource(`${sseBaseUrl()}api/irc/events?stream=${network}`, { withCredentials: true }) body: cmd
}),
events: (network: string) => new EventSource(
`${sseBaseUrl()}api/irc/events?stream=${encodeRFC3986URIComponent(network)}`,
{ withCredentials: true }
)
}, },
logs: { logs: {
files: () => appClient.Get<LogFileResponse>("api/logs/files"), files: () => appClient.Get<LogFileResponse>("api/logs/files"),
@ -163,51 +274,61 @@ export const APIClient = {
logs: () => new EventSource(`${sseBaseUrl()}api/events?stream=logs`, { withCredentials: true }) logs: () => new EventSource(`${sseBaseUrl()}api/events?stream=logs`, { withCredentials: true })
}, },
notifications: { notifications: {
getAll: () => appClient.Get<Notification[]>("api/notification"), getAll: () => appClient.Get<ServiceNotification[]>("api/notification"),
create: (notification: Notification) => appClient.Post("api/notification", notification), create: (notification: ServiceNotification) => appClient.Post("api/notification", {
update: (notification: Notification) => appClient.Put(`api/notification/${notification.id}`, notification), body: notification
}),
update: (notification: ServiceNotification) => appClient.Put(
`api/notification/${notification.id}`,
{ body: notification }
),
delete: (id: number) => appClient.Delete(`api/notification/${id}`), delete: (id: number) => appClient.Delete(`api/notification/${id}`),
test: (n: Notification) => appClient.Post("api/notification/test", n) test: (notification: ServiceNotification) => appClient.Post("api/notification/test", {
body: notification
})
}, },
release: { release: {
find: (query?: string) => appClient.Get<ReleaseFindResponse>(`api/release${query}`), find: (query?: string) => appClient.Get<ReleaseFindResponse>(`api/release${query}`),
findRecent: () => appClient.Get<ReleaseFindResponse>("api/release/recent"), findRecent: () => appClient.Get<ReleaseFindResponse>("api/release/recent"),
findQuery: (offset?: number, limit?: number, filters?: Array<ReleaseFilter>) => { findQuery: (offset?: number, limit?: number, filters?: ReleaseFilter[]) => {
const params = new URLSearchParams(); const params: Record<string, string[]> = {
if (offset !== undefined && offset > 0) indexer: [],
params.append("offset", offset.toString()); push_status: [],
q: []
if (limit !== undefined) };
params.append("limit", limit.toString());
filters?.forEach((filter) => { filters?.forEach((filter) => {
if (!filter.value) if (!filter.value)
return; return;
if (filter.id == "indexer") if (filter.id == "indexer") {
params.append("indexer", filter.value); params["indexer"].push(filter.value);
else if (filter.id === "action_status") } else if (filter.id === "action_status") {
params.append("push_status", filter.value); params["push_status"].push(filter.value);
else if (filter.id == "torrent_name") } else if (filter.id == "torrent_name") {
params.append("q", filter.value); params["q"].push(filter.value);
}
}); });
return appClient.Get<ReleaseFindResponse>(`api/release?${params.toString()}`); return appClient.Get<ReleaseFindResponse>("api/release", {
queryString: {
offset,
limit,
...params
}
});
}, },
indexerOptions: () => appClient.Get<string[]>("api/release/indexers"), indexerOptions: () => appClient.Get<string[]>("api/release/indexers"),
stats: () => appClient.Get<ReleaseStats>("api/release/stats"), stats: () => appClient.Get<ReleaseStats>("api/release/stats"),
delete: (olderThan: number) => { delete: (olderThan: number) => appClient.Delete("api/release", {
const params = new URLSearchParams(); queryString: { olderThan }
if (olderThan !== undefined && olderThan > 0) { }),
params.append("olderThan", olderThan.toString()); replayAction: (releaseId: number, actionId: number) => appClient.Post(
} `api/release/${releaseId}/actions/${actionId}/retry`
)
return appClient.Delete(`api/release?${params.toString()}`)
},
replayAction: (releaseId: number, actionId: number) => appClient.Post(`api/release/${releaseId}/actions/${actionId}/retry`)
}, },
updates: { updates: {
check: () => appClient.Get("api/updates/check"), check: () => appClient.Get("api/updates/check"),
getLatestRelease: () => appClient.Get<GithubRelease | undefined>("api/updates/latest") getLatestRelease: () => appClient.Get<GithubRelease>("api/updates/latest")
} }
}; };

View file

@ -181,7 +181,7 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (notification: Notification) => APIClient.notifications.create(notification), mutationFn: (notification: ServiceNotification) => APIClient.notifications.create(notification),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() }); queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
@ -193,16 +193,16 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) {
} }
}); });
const onSubmit = (formData: unknown) => createMutation.mutate(formData as Notification); const onSubmit = (formData: unknown) => createMutation.mutate(formData as ServiceNotification);
const testMutation = useMutation({ const testMutation = useMutation({
mutationFn: (n: Notification) => APIClient.notifications.test(n), mutationFn: (n: ServiceNotification) => APIClient.notifications.test(n),
onError: (err) => { onError: (err) => {
console.error(err); console.error(err);
} }
}); });
const testNotification = (data: unknown) => testMutation.mutate(data as Notification); const testNotification = (data: unknown) => testMutation.mutate(data as ServiceNotification);
const validate = (values: NotificationAddFormValues) => { const validate = (values: NotificationAddFormValues) => {
const errors = {} as FormikErrors<FormikValues>; const errors = {} as FormikErrors<FormikValues>;
@ -428,7 +428,7 @@ const EventCheckBoxes = () => (
interface UpdateProps { interface UpdateProps {
isOpen: boolean; isOpen: boolean;
toggle: () => void; toggle: () => void;
notification: Notification; notification: ServiceNotification;
} }
interface InitialValues { interface InitialValues {
@ -449,7 +449,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (notification: Notification) => APIClient.notifications.update(notification), mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() }); queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
@ -458,7 +458,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
} }
}); });
const onSubmit = (formData: unknown) => mutation.mutate(formData as Notification); const onSubmit = (formData: unknown) => mutation.mutate(formData as ServiceNotification);
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (notificationID: number) => APIClient.notifications.delete(notificationID), mutationFn: (notificationID: number) => APIClient.notifications.delete(notificationID),
@ -472,13 +472,13 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
const deleteAction = () => deleteMutation.mutate(notification.id); const deleteAction = () => deleteMutation.mutate(notification.id);
const testMutation = useMutation({ const testMutation = useMutation({
mutationFn: (n: Notification) => APIClient.notifications.test(n), mutationFn: (n: ServiceNotification) => APIClient.notifications.test(n),
onError: (err) => { onError: (err) => {
console.error(err); console.error(err);
} }
}); });
const testNotification = (data: unknown) => testMutation.mutate(data as Notification); const testNotification = (data: unknown) => testMutation.mutate(data as ServiceNotification);
const initialValues: InitialValues = { const initialValues: InitialValues = {
id: notification.id, id: notification.id,

View file

@ -64,7 +64,7 @@ function NotificationSettings() {
<div className="hidden md:flex col-span-3 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Events</div> <div className="hidden md:flex col-span-3 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Events</div>
</li> </li>
{data && data.map((n: Notification) => ( {data && data.map((n: ServiceNotification) => (
<ListItem key={n.id} notification={n} /> <ListItem key={n.id} notification={n} />
))} ))}
</ol> </ol>
@ -108,7 +108,7 @@ const iconComponentMap: componentMapType = {
}; };
interface ListItemProps { interface ListItemProps {
notification: Notification; notification: ServiceNotification;
} }
function ListItem({ notification }: ListItemProps) { function ListItem({ notification }: ListItemProps) {
@ -117,8 +117,8 @@ function ListItem({ notification }: ListItemProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (notification: Notification) => APIClient.notifications.update(notification).then(() => notification), mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification).then(() => notification),
onSuccess: (notification: Notification) => { onSuccess: (notification: ServiceNotification) => {
toast.custom(t => <Toast type="success" body={`${notification.name} was ${notification.enabled ? "enabled" : "disabled"} successfully.`} t={t} />); toast.custom(t => <Toast type="success" body={`${notification.name} was ${notification.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() }); queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
} }

View file

@ -12,7 +12,7 @@ type NotificationEvent =
| "IRC_RECONNECTED" | "IRC_RECONNECTED"
| "APP_UPDATE_AVAILABLE"; | "APP_UPDATE_AVAILABLE";
interface Notification { interface ServiceNotification {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;