This commit is contained in:
user
2026-03-27 20:06:38 +02:00
commit 8c45efc92e
544 changed files with 33060 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
import { FlowExecCtx } from "@/core/flow.execution.context";
import { okAsync } from "neverthrow";
import {
NotificationFilters,
PaginationOptions,
} from "./data";
import { NotificationRepository } from "./repository";
import { db } from "@pkg/db";
export class NotificationController {
constructor(private notifsRepo: NotificationRepository) {}
getNotifications(
fctx: FlowExecCtx,
filters: NotificationFilters,
pagination: PaginationOptions,
) {
return this.notifsRepo.getNotifications(fctx, filters, pagination);
}
markAsRead(
fctx: FlowExecCtx,
notificationIds: number[],
userId: string,
) {
return this.notifsRepo.markAsRead(fctx, notificationIds, userId);
}
markAsUnread(
fctx: FlowExecCtx,
notificationIds: number[],
userId: string,
) {
return this.notifsRepo.markAsUnread(fctx, notificationIds, userId);
}
archive(
fctx: FlowExecCtx,
notificationIds: number[],
userId: string,
) {
return this.notifsRepo.archive(fctx, notificationIds, userId);
}
unarchive(
fctx: FlowExecCtx,
notificationIds: number[],
userId: string,
) {
return this.notifsRepo.unarchive(fctx, notificationIds, userId);
}
deleteNotifications(
fctx: FlowExecCtx,
notificationIds: number[],
userId: string,
) {
return this.notifsRepo.deleteNotifications(fctx, notificationIds, userId);
}
getUnreadCount(
fctx: FlowExecCtx,
userId: string,
) {
return this.notifsRepo.getUnreadCount(fctx, userId);
}
markAllAsRead(
fctx: FlowExecCtx,
userId: string,
) {
// Get all unread notification IDs for this user
const filters: NotificationFilters = {
userId,
isRead: false,
isArchived: false,
};
// Get a large number to handle bulk operations
const pagination: PaginationOptions = { page: 1, pageSize: 1000 };
return this.notifsRepo
.getNotifications(fctx, filters, pagination)
.map((paginated) => paginated.data.map((n) => n.id))
.andThen((notificationIds) => {
if (notificationIds.length === 0) {
return okAsync(true);
}
return this.notifsRepo.markAsRead(fctx, notificationIds, userId);
});
}
}
export function getNotificationController(): NotificationController {
return new NotificationController(new NotificationRepository(db));
}

View File

@@ -0,0 +1,115 @@
import * as v from "valibot";
// Notification schema
export const notificationSchema = v.object({
id: v.pipe(v.number(), v.integer()),
title: v.string(),
body: v.string(),
priority: v.string(),
type: v.string(),
category: v.string(),
isRead: v.boolean(),
isArchived: v.boolean(),
actionUrl: v.string(),
actionType: v.string(),
actionData: v.string(),
icon: v.string(),
userId: v.string(),
sentAt: v.date(),
readAt: v.nullable(v.date()),
expiresAt: v.nullable(v.date()),
createdAt: v.date(),
updatedAt: v.date(),
});
export type Notification = v.InferOutput<typeof notificationSchema>;
export type Notifications = Notification[];
// Notification filters schema
export const notificationFiltersSchema = v.object({
userId: v.string(),
isRead: v.optional(v.boolean()),
isArchived: v.optional(v.boolean()),
type: v.optional(v.string()),
category: v.optional(v.string()),
priority: v.optional(v.string()),
search: v.optional(v.string()),
});
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()),
pageSize: v.pipe(v.number(), v.integer()),
sortBy: v.optional(v.string()),
sortOrder: v.optional(v.string()),
});
export type PaginationOptions = v.InferOutput<typeof paginationOptionsSchema>;
// Paginated notifications schema
export const paginatedNotificationsSchema = v.object({
data: v.array(notificationSchema),
total: v.pipe(v.number(), v.integer()),
page: v.pipe(v.number(), v.integer()),
pageSize: v.pipe(v.number(), v.integer()),
totalPages: v.pipe(v.number(), v.integer()),
});
export type PaginatedNotifications = v.InferOutput<
typeof paginatedNotificationsSchema
>;
// Get notifications schema
export const getNotificationsSchema = v.object({
filters: notificationFiltersSchema,
pagination: paginationOptionsSchema,
});
export type GetNotifications = v.InferOutput<typeof getNotificationsSchema>;
// Bulk notification IDs schema
export const bulkNotificationIdsSchema = v.object({
notificationIds: v.array(v.pipe(v.number(), v.integer())),
});
export type BulkNotificationIds = v.InferOutput<
typeof bulkNotificationIdsSchema
>;
// View Model specific types
export const clientNotificationFiltersSchema = v.object({
userId: v.string(),
isRead: v.optional(v.boolean()),
isArchived: v.optional(v.boolean()),
type: v.optional(v.string()),
category: v.optional(v.string()),
priority: v.optional(v.string()),
search: v.optional(v.string()),
});
export type ClientNotificationFilters = v.InferOutput<
typeof clientNotificationFiltersSchema
>;
export const clientPaginationStateSchema = v.object({
page: v.pipe(v.number(), v.integer()),
pageSize: v.pipe(v.number(), v.integer()),
total: v.pipe(v.number(), v.integer()),
totalPages: v.pipe(v.number(), v.integer()),
sortBy: v.picklist(["createdAt", "sentAt", "readAt", "priority"]),
sortOrder: v.picklist(["asc", "desc"]),
});
export type ClientPaginationState = v.InferOutput<
typeof clientPaginationStateSchema
>;

View File

@@ -0,0 +1,78 @@
import { FlowExecCtx } from "@/core/flow.execution.context";
import { ERROR_CODES, type Err } from "@pkg/result";
import { getError } from "@pkg/logger";
export const notificationErrors = {
dbError: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Database operation failed",
description: "Please try again later",
detail,
}),
getNotificationsFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch notifications",
description: "Please try again later",
detail,
}),
markAsReadFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to mark notifications as read",
description: "Please try again later",
detail,
}),
markAsUnreadFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to mark notifications as unread",
description: "Please try again later",
detail,
}),
archiveFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to archive notifications",
description: "Please try again later",
detail,
}),
unarchiveFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to unarchive notifications",
description: "Please try again later",
detail,
}),
deleteNotificationsFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to delete notifications",
description: "Please try again later",
detail,
}),
getUnreadCountFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to get unread count",
description: "Please try again later",
detail,
}),
};

View File

@@ -0,0 +1,453 @@
import { and, asc, count, Database, desc, eq, like, or, sql } from "@pkg/db";
import { notifications } from "@pkg/db/schema";
import { ResultAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context";
import type {
Notification,
NotificationFilters,
PaginatedNotifications,
PaginationOptions,
} from "./data";
import { type Err } from "@pkg/result";
import { notificationErrors } from "./errors";
import { logDomainEvent } from "@pkg/logger";
export class NotificationRepository {
constructor(private db: Database) {}
getNotifications(
fctx: FlowExecCtx,
filters: NotificationFilters,
pagination: PaginationOptions,
): ResultAsync<PaginatedNotifications, Err> {
const startedAt = Date.now();
logDomainEvent({
event: "notifications.list.started",
fctx,
meta: {
hasSearch: Boolean(filters.search),
isRead: filters.isRead,
isArchived: filters.isArchived,
page: pagination.page,
pageSize: pagination.pageSize,
sortBy: pagination.sortBy,
sortOrder: pagination.sortOrder,
},
});
const { userId, isRead, isArchived, type, category, priority, search } =
filters;
const {
page,
pageSize,
sortBy = "createdAt",
sortOrder = "desc",
} = pagination;
// Build WHERE conditions
const conditions = [eq(notifications.userId, userId)];
if (isRead !== undefined) {
conditions.push(eq(notifications.isRead, isRead));
}
if (isArchived !== undefined) {
conditions.push(eq(notifications.isArchived, isArchived));
}
if (type) {
conditions.push(eq(notifications.type, type));
}
if (category) {
conditions.push(eq(notifications.category, category));
}
if (priority) {
conditions.push(eq(notifications.priority, priority));
}
if (search) {
conditions.push(
or(
like(notifications.title, `%${search}%`),
like(notifications.body, `%${search}%`),
)!,
);
}
const whereClause = and(...conditions);
return ResultAsync.fromPromise(
this.db.select({ count: count() }).from(notifications).where(whereClause),
(error) => {
logDomainEvent({
level: "error",
event: "notifications.list.failed",
fctx,
durationMs: Date.now() - startedAt,
error,
});
return notificationErrors.getNotificationsFailed(
fctx,
error instanceof Error ? error.message : String(error),
);
},
).andThen((totalResult) => {
const total = totalResult[0]?.count || 0;
const offset = (page - 1) * pageSize;
// Map sortBy to proper column
const getOrderColumn = (sortBy: string) => {
switch (sortBy) {
case "createdAt":
return notifications.createdAt;
case "sentAt":
return notifications.sentAt;
case "readAt":
return notifications.readAt;
case "priority":
return notifications.priority;
default:
return notifications.createdAt;
}
};
const orderColumn = getOrderColumn(sortBy);
const orderFunc = sortOrder === "asc" ? asc : desc;
return ResultAsync.fromPromise(
this.db
.select()
.from(notifications)
.where(whereClause)
.orderBy(orderFunc(orderColumn))
.limit(pageSize)
.offset(offset),
(error) => {
logDomainEvent({
level: "error",
event: "notifications.list.failed",
fctx,
durationMs: Date.now() - startedAt,
error,
});
return notificationErrors.getNotificationsFailed(
fctx,
error instanceof Error ? error.message : String(error),
);
},
).map((data) => {
const totalPages = Math.ceil(total / pageSize);
logDomainEvent({
event: "notifications.list.succeeded",
fctx,
durationMs: Date.now() - startedAt,
meta: {
count: data.length,
page,
totalPages,
},
});
return {
data: data as Notification[],
total,
page,
pageSize,
totalPages,
};
});
});
}
markAsRead(
fctx: FlowExecCtx,
notificationIds: number[],
userId: string,
): ResultAsync<boolean, Err> {
const startedAt = Date.now();
logDomainEvent({
event: "notifications.mark_read.started",
fctx,
meta: { userId, notificationCount: notificationIds.length },
});
return ResultAsync.fromPromise(
this.db
.update(notifications)
.set({
isRead: true,
readAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(notifications.userId, userId),
sql`${notifications.id} = ANY(${notificationIds})`,
),
),
(error) => {
logDomainEvent({
level: "error",
event: "notifications.mark_read.failed",
fctx,
durationMs: Date.now() - startedAt,
error,
});
return notificationErrors.markAsReadFailed(
fctx,
error instanceof Error ? error.message : String(error),
);
},
).map(() => {
logDomainEvent({
event: "notifications.mark_read.succeeded",
fctx,
durationMs: Date.now() - startedAt,
meta: { notificationCount: notificationIds.length },
});
return true;
});
}
markAsUnread(
fctx: FlowExecCtx,
notificationIds: number[],
userId: string,
): ResultAsync<boolean, Err> {
const startedAt = Date.now();
logDomainEvent({
event: "notifications.mark_unread.started",
fctx,
meta: { userId, notificationCount: notificationIds.length },
});
return ResultAsync.fromPromise(
this.db
.update(notifications)
.set({
isRead: false,
readAt: null,
updatedAt: new Date(),
})
.where(
and(
eq(notifications.userId, userId),
sql`${notifications.id} = ANY(${notificationIds})`,
),
),
(error) => {
logDomainEvent({
level: "error",
event: "notifications.mark_unread.failed",
fctx,
durationMs: Date.now() - startedAt,
error,
});
return notificationErrors.markAsUnreadFailed(
fctx,
error instanceof Error ? error.message : String(error),
);
},
).map(() => {
logDomainEvent({
event: "notifications.mark_unread.succeeded",
fctx,
durationMs: Date.now() - startedAt,
meta: { notificationCount: notificationIds.length },
});
return true;
});
}
archive(
fctx: FlowExecCtx,
notificationIds: number[],
userId: string,
): ResultAsync<boolean, Err> {
const startedAt = Date.now();
logDomainEvent({
event: "notifications.archive.started",
fctx,
meta: { userId, notificationCount: notificationIds.length },
});
return ResultAsync.fromPromise(
this.db
.update(notifications)
.set({
isArchived: true,
updatedAt: new Date(),
})
.where(
and(
eq(notifications.userId, userId),
sql`${notifications.id} = ANY(${notificationIds})`,
),
),
(error) => {
logDomainEvent({
level: "error",
event: "notifications.archive.failed",
fctx,
durationMs: Date.now() - startedAt,
error,
});
return notificationErrors.archiveFailed(
fctx,
error instanceof Error ? error.message : String(error),
);
},
).map(() => {
logDomainEvent({
event: "notifications.archive.succeeded",
fctx,
durationMs: Date.now() - startedAt,
meta: { notificationCount: notificationIds.length },
});
return true;
});
}
unarchive(
fctx: FlowExecCtx,
notificationIds: number[],
userId: string,
): ResultAsync<boolean, Err> {
const startedAt = Date.now();
logDomainEvent({
event: "notifications.unarchive.started",
fctx,
meta: { userId, notificationCount: notificationIds.length },
});
return ResultAsync.fromPromise(
this.db
.update(notifications)
.set({
isArchived: false,
updatedAt: new Date(),
})
.where(
and(
eq(notifications.userId, userId),
sql`${notifications.id} = ANY(${notificationIds})`,
),
),
(error) => {
logDomainEvent({
level: "error",
event: "notifications.unarchive.failed",
fctx,
durationMs: Date.now() - startedAt,
error,
});
return notificationErrors.unarchiveFailed(
fctx,
error instanceof Error ? error.message : String(error),
);
},
).map(() => {
logDomainEvent({
event: "notifications.unarchive.succeeded",
fctx,
durationMs: Date.now() - startedAt,
meta: { notificationCount: notificationIds.length },
});
return true;
});
}
deleteNotifications(
fctx: FlowExecCtx,
notificationIds: number[],
userId: string,
): ResultAsync<boolean, Err> {
const startedAt = Date.now();
logDomainEvent({
event: "notifications.delete.started",
fctx,
meta: { userId, notificationCount: notificationIds.length },
});
return ResultAsync.fromPromise(
this.db
.delete(notifications)
.where(
and(
eq(notifications.userId, userId),
sql`${notifications.id} = ANY(${notificationIds})`,
),
),
(error) => {
logDomainEvent({
level: "error",
event: "notifications.delete.failed",
fctx,
durationMs: Date.now() - startedAt,
error,
});
return notificationErrors.deleteNotificationsFailed(
fctx,
error instanceof Error ? error.message : String(error),
);
},
).map(() => {
logDomainEvent({
event: "notifications.delete.succeeded",
fctx,
durationMs: Date.now() - startedAt,
meta: { notificationCount: notificationIds.length },
});
return true;
});
}
getUnreadCount(
fctx: FlowExecCtx,
userId: string,
): ResultAsync<number, Err> {
const startedAt = Date.now();
logDomainEvent({
event: "notifications.unread_count.started",
fctx,
meta: { userId },
});
return ResultAsync.fromPromise(
this.db
.select({ count: count() })
.from(notifications)
.where(
and(
eq(notifications.userId, userId),
eq(notifications.isRead, false),
eq(notifications.isArchived, false),
),
),
(error) => {
logDomainEvent({
level: "error",
event: "notifications.unread_count.failed",
fctx,
durationMs: Date.now() - startedAt,
error,
});
return notificationErrors.getUnreadCountFailed(
fctx,
error instanceof Error ? error.message : String(error),
);
},
).map((result) => {
const count = result[0]?.count || 0;
logDomainEvent({
event: "notifications.unread_count.succeeded",
fctx,
durationMs: Date.now() - startedAt,
meta: { count },
});
return count;
});
}
}