native-ified the notifications domain

This commit is contained in:
user
2026-03-01 04:06:54 +02:00
parent cf7fa2663c
commit 6b3ecc3aad
6 changed files with 342 additions and 430 deletions

View File

@@ -0,0 +1,11 @@
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
export async function getFlowExecCtxForRemoteFuncs(
locals: App.Locals,
): Promise<FlowExecCtx> {
return {
flowId: locals.flowId || crypto.randomUUID(),
userId: locals.user?.id,
sessionId: locals.session?.id,
};
}

View File

@@ -0,0 +1,22 @@
import { ensureAccountExistsSchema } from "@pkg/logic/domains/user/data";
import { command, getRequestEvent, query } from "$app/server";
export const getMyInfoSQ = query(async () => {
const event = getRequestEvent();
// .... do stuff usually done in a router here
return { data: "testing" };
});
export const getUsersInfoByIdSQ = query(async () => {
// .... do stuff usually done in a router here
return { data: "testing" };
});
export const ensureAccountExistsSQ = command(
ensureAccountExistsSchema,
async (payload) => {
// .... do stuff usually done in a router here
return { data: "testing" };
},
);

View File

@@ -3,18 +3,25 @@ import type {
ClientPaginationState,
Notifications,
} from "@pkg/logic/domains/notifications/data";
import { apiClient, user } from "$lib/global.stores";
import {
archiveSC,
deleteNotificationsSC,
getNotificationsSQ,
getUnreadCountSQ,
markAllReadSC,
markReadSC,
markUnreadSC,
unarchiveSC,
} from "./notifications.remote";
import { user } from "$lib/global.stores";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import type { Err } from "@pkg/result";
class NotificationViewModel {
notifications = $state([] as Notifications);
loading = $state(false);
selectedIds = $state(new Set<number>());
// Pagination state
pagination = $state<ClientPaginationState>({
page: 1,
pageSize: 20,
@@ -24,465 +31,157 @@ class NotificationViewModel {
sortOrder: "desc",
});
// Filter state
filters = $state<ClientNotificationFilters>({
userId: get(user)?.id!,
isArchived: false, // Default to showing non-archived
isArchived: false,
});
// Stats
unreadCount = $state(0);
private getFetchQueryInput() {
return {
filters: {
userId: this.filters.userId,
isRead: this.filters.isRead,
isArchived: this.filters.isArchived,
type: this.filters.type,
category: this.filters.category,
priority: this.filters.priority,
search: this.filters.search,
},
pagination: {
page: this.pagination.page,
pageSize: this.pagination.pageSize,
sortBy: this.pagination.sortBy,
sortOrder: this.pagination.sortOrder,
},
};
}
private async runCommand(
fn: (payload: { notificationIds: number[] }) => Promise<any>,
notificationIds: number[],
errorMessage: string,
after: Array<() => Promise<void>>,
) {
try {
const result = await fn({ notificationIds });
if (result?.error) {
toast.error(result.error.message || errorMessage, {
description: result.error.description || "Please try again later",
});
return;
}
for (const action of after) {
await action();
}
} catch (error) {
toast.error(errorMessage, {
description:
error instanceof Error ? error.message : "Please try again later",
});
}
}
async fetchNotifications() {
this.loading = true;
const params = new URLSearchParams();
// Add pagination params
params.append("page", this.pagination.page.toString());
params.append("pageSize", this.pagination.pageSize.toString());
params.append("sortBy", this.pagination.sortBy);
params.append("sortOrder", this.pagination.sortOrder);
// Add filter params
if (this.filters.isRead !== undefined) {
params.append("isRead", this.filters.isRead.toString());
}
if (this.filters.isArchived !== undefined) {
params.append("isArchived", this.filters.isArchived.toString());
}
if (this.filters.type) {
params.append("type", this.filters.type);
}
if (this.filters.category) {
params.append("category", this.filters.category);
}
if (this.filters.priority) {
params.append("priority", this.filters.priority);
}
if (this.filters.search) {
params.append("search", this.filters.search);
}
const result = await ResultAsync.fromPromise(
get(apiClient).notifications.$get({
query: Object.fromEntries(params.entries()),
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to fetch notifications",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to fetch notifications",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult: any) => {
if (apiResult.error || !apiResult.data) {
return errAsync(
apiResult.error || {
code: "API_ERROR",
message: "Failed to fetch notifications",
description: "Invalid response data",
detail: "Missing data in response",
try {
const result = await getNotificationsSQ(this.getFetchQueryInput());
if (result?.error || !result?.data) {
toast.error(
result?.error?.message || "Failed to fetch notifications",
{
description:
result?.error?.description || "Please try again later",
},
);
return;
}
return okAsync(apiResult.data);
});
result.match(
(data) => {
this.notifications = data.data as any;
this.pagination.total = data.total;
this.pagination.totalPages = data.totalPages;
},
(error) => {
const errorMessage = error.message || "Failed to fetch notifications";
toast.error(errorMessage, {
description: error.description || "Please try again later",
this.notifications = result.data.data as Notifications;
this.pagination.total = result.data.total;
this.pagination.totalPages = result.data.totalPages;
} catch (error) {
toast.error("Failed to fetch notifications", {
description:
error instanceof Error ? error.message : "Please try again later",
});
},
);
} finally {
this.loading = false;
}
}
async markAsRead(notificationIds: number[]) {
const result = await ResultAsync.fromPromise(
get(apiClient).notifications["mark-read"].$put({
json: { notificationIds },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to mark as read",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to mark as read",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult: any) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data);
});
result.match(
async () => {
await this.fetchNotifications();
await this.fetchUnreadCount();
},
(error) => {
const errorMessage = error.message || "Failed to mark as read";
toast.error(errorMessage, {
description: error.description || "Please try again later",
});
},
);
await this.runCommand(markReadSC, notificationIds, "Failed to mark as read", [
() => this.fetchNotifications(),
() => this.fetchUnreadCount(),
]);
}
async markAsUnread(notificationIds: number[]) {
const result = await ResultAsync.fromPromise(
get(apiClient).notifications["mark-unread"].$put({
json: { notificationIds },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to mark as unread",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to mark as unread",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult: any) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data);
});
result.match(
async () => {
await this.fetchNotifications();
await this.fetchUnreadCount();
},
(error) => {
const errorMessage = error.message || "Failed to mark as unread";
toast.error(errorMessage, {
description: error.description || "Please try again later",
});
},
await this.runCommand(
markUnreadSC,
notificationIds,
"Failed to mark as unread",
[() => this.fetchNotifications(), () => this.fetchUnreadCount()],
);
}
async archive(notificationIds: number[]) {
const result = await ResultAsync.fromPromise(
get(apiClient).notifications.archive.$put({
json: { notificationIds },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to archive",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to archive",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult: any) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data);
});
result.match(
async () => {
await this.fetchNotifications();
},
(error) => {
const errorMessage = error.message || "Failed to archive";
toast.error(errorMessage, {
description: error.description || "Please try again later",
});
},
);
await this.runCommand(archiveSC, notificationIds, "Failed to archive", [
() => this.fetchNotifications(),
]);
}
async unarchive(notificationIds: number[]) {
const result = await ResultAsync.fromPromise(
get(apiClient).notifications.unarchive.$put({
json: { notificationIds },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to unarchive",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to unarchive",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult: any) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data);
});
result.match(
async () => {
await this.fetchNotifications();
},
(error) => {
const errorMessage = error.message || "Failed to unarchive";
toast.error(errorMessage, {
description: error.description || "Please try again later",
});
},
);
await this.runCommand(unarchiveSC, notificationIds, "Failed to unarchive", [
() => this.fetchNotifications(),
]);
}
async deleteNotifications(notificationIds: number[]) {
const result = await ResultAsync.fromPromise(
get(apiClient).notifications.delete.$delete({
json: { notificationIds },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to delete",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to delete",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult: any) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data);
});
result.match(
async () => {
await this.fetchNotifications();
await this.fetchUnreadCount();
},
(error) => {
const errorMessage = error.message || "Failed to delete";
toast.error(errorMessage, {
description: error.description || "Please try again later",
});
},
await this.runCommand(
deleteNotificationsSC,
notificationIds,
"Failed to delete",
[() => this.fetchNotifications(), () => this.fetchUnreadCount()],
);
}
async markAllAsRead() {
const result = await ResultAsync.fromPromise(
get(apiClient).notifications["mark-all-read"].$put(),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to mark all as read",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to mark all as read",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
try {
const result = await markAllReadSC({});
if (result?.error) {
toast.error(result.error.message || "Failed to mark all as read", {
description: result.error.description || "Please try again later",
});
return;
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult: any) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data);
});
result.match(
async () => {
await this.fetchNotifications();
await this.fetchUnreadCount();
},
(error) => {
const errorMessage = error.message || "Failed to mark all as read";
toast.error(errorMessage, {
description: error.description || "Please try again later",
} catch (error) {
toast.error("Failed to mark all as read", {
description:
error instanceof Error ? error.message : "Please try again later",
});
},
);
}
}
async fetchUnreadCount() {
const result = await ResultAsync.fromPromise(
get(apiClient).notifications["unread-count"].$get(),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to fetch unread count",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to fetch unread count",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
try {
const result = await getUnreadCountSQ();
if (result?.error) {
return;
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult: any) => {
if (apiResult.error) {
return errAsync(apiResult.error);
if (result?.data !== undefined && result?.data !== null) {
this.unreadCount = result.data as number;
}
return okAsync(apiResult.data);
});
result.match(
(data) => {
if (data !== undefined && data !== null) {
this.unreadCount = data as number;
} catch {
// Intentionally silent - unread count is non-critical UI data.
}
},
(error) => {
// Silently fail for unread count - don't show toast as it's not critical
console.error("Failed to fetch unread count:", error);
},
);
}
// Helper methods
toggleSelection(id: number) {
if (this.selectedIds.has(id)) {
this.selectedIds.delete(id);
@@ -506,7 +205,7 @@ class NotificationViewModel {
setPageSize(pageSize: number) {
this.pagination.pageSize = pageSize;
this.pagination.page = 1; // Reset to first page
this.pagination.page = 1;
this.fetchNotifications();
}
@@ -516,13 +215,13 @@ class NotificationViewModel {
) {
this.pagination.sortBy = sortBy;
this.pagination.sortOrder = sortOrder;
this.pagination.page = 1; // Reset to first page
this.pagination.page = 1;
this.fetchNotifications();
}
setFilters(newFilters: Partial<ClientNotificationFilters>) {
this.filters = { ...this.filters, ...newFilters };
this.pagination.page = 1; // Reset to first page
this.pagination.page = 1;
this.fetchNotifications();
}

View File

@@ -0,0 +1,167 @@
import {
bulkNotificationIdsSchema,
getNotificationsSchema,
} from "@pkg/logic/domains/notifications/data";
import { getNotificationController } from "@pkg/logic/domains/notifications/controller";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { getFlowExecCtxForRemoteFuncs } from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server";
import type { Err } from "@pkg/result";
import * as v from "valibot";
const nc = getNotificationController();
export async function unauthorized(fctx: FlowExecCtx) {
return {
data: null,
error: {
flowId: fctx.flowId,
code: "UNAUTHORIZED",
message: "User not authenticated",
description: "Please log in",
detail: "No user found in request locals",
} as Err,
};
}
export const getNotificationsSQ = query(
getNotificationsSchema,
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.getNotifications(
fctx,
{ ...input.filters, userId: fctx.userId },
input.pagination,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const markReadSC = command(
bulkNotificationIdsSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.markAsRead(
fctx,
[...payload.notificationIds],
fctx.userId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const markUnreadSC = command(
bulkNotificationIdsSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.markAsUnread(
fctx,
[...payload.notificationIds],
fctx.userId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const archiveSC = command(bulkNotificationIdsSchema, async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.archive(
fctx,
[...payload.notificationIds],
fctx.userId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const unarchiveSC = command(
bulkNotificationIdsSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.unarchive(
fctx,
[...payload.notificationIds],
fctx.userId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const deleteNotificationsSC = command(
bulkNotificationIdsSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.deleteNotifications(
fctx,
[...payload.notificationIds],
fctx.userId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const markAllReadSC = command(v.object({}), async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.markAllAsRead(fctx, fctx.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const getUnreadCountSQ = query(async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.getUnreadCount(fctx, fctx.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});

View File

@@ -39,6 +39,19 @@ export type NotificationFilters = v.InferOutput<
typeof notificationFiltersSchema
>;
export type NotificationsQueryInput = {
isRead?: boolean;
isArchived?: boolean;
type?: string;
category?: string;
priority?: string;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
// Pagination options schema
export const paginationOptionsSchema = v.object({
page: v.pipe(v.number(), v.integer()),