From 6b3ecc3aad28338f46803292b31251ca7ff1fbd6 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 1 Mar 2026 04:06:54 +0200 Subject: [PATCH] native-ified the notifications domain --- apps/main/src/lib/core/server.utils.ts | 11 + .../src/lib/domains/account/account.remote.ts | 22 + .../sessions/sessions.remote.ts} | 0 .../notifications/notification.vm.svelte.ts | 559 ++++-------------- .../notifications/notifications.remote.ts | 167 ++++++ packages/logic/domains/notifications/data.ts | 13 + 6 files changed, 342 insertions(+), 430 deletions(-) create mode 100644 apps/main/src/lib/core/server.utils.ts create mode 100644 apps/main/src/lib/domains/account/account.remote.ts rename apps/main/src/lib/domains/{todo/list.svelte => account/sessions/sessions.remote.ts} (100%) create mode 100644 apps/main/src/lib/domains/notifications/notifications.remote.ts diff --git a/apps/main/src/lib/core/server.utils.ts b/apps/main/src/lib/core/server.utils.ts new file mode 100644 index 0000000..10c586c --- /dev/null +++ b/apps/main/src/lib/core/server.utils.ts @@ -0,0 +1,11 @@ +import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context"; + +export async function getFlowExecCtxForRemoteFuncs( + locals: App.Locals, +): Promise { + return { + flowId: locals.flowId || crypto.randomUUID(), + userId: locals.user?.id, + sessionId: locals.session?.id, + }; +} diff --git a/apps/main/src/lib/domains/account/account.remote.ts b/apps/main/src/lib/domains/account/account.remote.ts new file mode 100644 index 0000000..aac3c36 --- /dev/null +++ b/apps/main/src/lib/domains/account/account.remote.ts @@ -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" }; + }, +); diff --git a/apps/main/src/lib/domains/todo/list.svelte b/apps/main/src/lib/domains/account/sessions/sessions.remote.ts similarity index 100% rename from apps/main/src/lib/domains/todo/list.svelte rename to apps/main/src/lib/domains/account/sessions/sessions.remote.ts diff --git a/apps/main/src/lib/domains/notifications/notification.vm.svelte.ts b/apps/main/src/lib/domains/notifications/notification.vm.svelte.ts index 0ad8ca9..a9d23a8 100644 --- a/apps/main/src/lib/domains/notifications/notification.vm.svelte.ts +++ b/apps/main/src/lib/domains/notifications/notification.vm.svelte.ts @@ -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()); - // Pagination state pagination = $state({ page: 1, pageSize: 20, @@ -24,465 +31,157 @@ class NotificationViewModel { sortOrder: "desc", }); - // Filter state filters = $state({ 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, + notificationIds: number[], + errorMessage: string, + after: Array<() => Promise>, + ) { + 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), - }), + 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", + }, ); - }) - .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", - }, - ); - } - return okAsync(apiResult.data); + return; + } + + 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", }); - - 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.loading = false; + } 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}`, - }); - } - 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", + 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; + } + await this.fetchNotifications(); + await this.fetchUnreadCount(); + } 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}`, - }); - } - 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( - (data) => { - if (data !== undefined && data !== null) { - this.unreadCount = data as number; - } - }, - (error) => { - // Silently fail for unread count - don't show toast as it's not critical - console.error("Failed to fetch unread count:", error); - }, - ); + try { + const result = await getUnreadCountSQ(); + if (result?.error) { + return; + } + if (result?.data !== undefined && result?.data !== null) { + this.unreadCount = result.data as number; + } + } catch { + // Intentionally silent - unread count is non-critical UI data. + } } - // 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) { this.filters = { ...this.filters, ...newFilters }; - this.pagination.page = 1; // Reset to first page + this.pagination.page = 1; this.fetchNotifications(); } diff --git a/apps/main/src/lib/domains/notifications/notifications.remote.ts b/apps/main/src/lib/domains/notifications/notifications.remote.ts new file mode 100644 index 0000000..0e105c8 --- /dev/null +++ b/apps/main/src/lib/domains/notifications/notifications.remote.ts @@ -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 }; +}); diff --git a/packages/logic/domains/notifications/data.ts b/packages/logic/domains/notifications/data.ts index b95eefd..0f584f7 100644 --- a/packages/logic/domains/notifications/data.ts +++ b/packages/logic/domains/notifications/data.ts @@ -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()),