From 72194611298103ac6b10aba378b6d67480fc081a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aaron=20D=C3=B6tsch?= <aaron@fsmpi.rwth-aachen.de> Date: Fri, 4 Aug 2023 02:55:01 +0200 Subject: [PATCH] Implement notification system Many users requested to be able to get some kind of message when articles are bought. This commit is the base for notifying users about several different actions, e.g. buying articles or depositing money. --- .../migration.sql | 13 ++++ prisma/schema.prisma | 30 +++++--- src/lib/notifications/channelTypes.js | 13 ++++ src/lib/notifications/emailHandler.ts | 8 ++ src/lib/notifications/formatter.ts | 77 +++++++++++++++++++ src/lib/notifications/handler.ts | 38 +++++++++ src/lib/notifications/notificationTypes.js | 19 +++++ src/lib/notifications/types.ts | 35 +++++++++ src/lib/notifications/webhookHandler.ts | 15 ++++ 9 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20230803120315_create_notification_channel_table/migration.sql create mode 100644 src/lib/notifications/channelTypes.js create mode 100644 src/lib/notifications/emailHandler.ts create mode 100644 src/lib/notifications/formatter.ts create mode 100644 src/lib/notifications/handler.ts create mode 100644 src/lib/notifications/notificationTypes.js create mode 100644 src/lib/notifications/types.ts create mode 100644 src/lib/notifications/webhookHandler.ts diff --git a/prisma/migrations/20230803120315_create_notification_channel_table/migration.sql b/prisma/migrations/20230803120315_create_notification_channel_table/migration.sql new file mode 100644 index 0000000..ef3b648 --- /dev/null +++ b/prisma/migrations/20230803120315_create_notification_channel_table/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "NotificationChannel" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "channelType" INTEGER NOT NULL, + "notificationTypes" INTEGER NOT NULL, + "recipient" TEXT NOT NULL, + + CONSTRAINT "NotificationChannel_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "NotificationChannel" ADD CONSTRAINT "NotificationChannel_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 00f3dfb..9f37714 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,20 +11,21 @@ datasource db { } model User { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) email String name String - balance Int @default(0) - createdAt DateTime @default(now()) - transactions Transaction[] @relation("user") - transactionsVerified Transaction[] @relation("verifiedBy") + balance Int @default(0) + createdAt DateTime @default(now()) + transactions Transaction[] @relation("user") + transactionsVerified Transaction[] @relation("verifiedBy") itemTransactions ItemTransaction[] cards UserCard[] - moneyTransfersSent MoneyTransfer[] @relation("from") - moneyTransfersReceived MoneyTransfer[] @relation("to") - vouchersUsed Voucher[] @relation("vouchersUsed") - updatedAt DateTime @updatedAt - comment String @default("") + moneyTransfersSent MoneyTransfer[] @relation("from") + moneyTransfersReceived MoneyTransfer[] @relation("to") + vouchersUsed Voucher[] @relation("vouchersUsed") + notificationChannels NotificationChannel[] + updatedAt DateTime @updatedAt + comment String @default("") } model UserCard { @@ -113,3 +114,12 @@ model Voucher { usedById Int? usedBy User? @relation("vouchersUsed", fields: [usedById], references: [id]) } + +model NotificationChannel { + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id]) + channelType Int + notificationTypes Int + recipient String +} diff --git a/src/lib/notifications/channelTypes.js b/src/lib/notifications/channelTypes.js new file mode 100644 index 0000000..ffbf120 --- /dev/null +++ b/src/lib/notifications/channelTypes.js @@ -0,0 +1,13 @@ +import { handleWebhook } from "./webhookHandler"; +import { handleEmail } from "./emailHandler"; + +/** + * @type {{ + * WEBHOOK: import("./types").ChannelType<?>, + * EMAIL: import("./types").ChannelType<?> + * }} + */ +export const ChannelType = { + WEBHOOK: {key: 1<<0, handler: handleWebhook}, + EMAIL: {key: 1<<1, handler: handleEmail}, +}; diff --git a/src/lib/notifications/emailHandler.ts b/src/lib/notifications/emailHandler.ts new file mode 100644 index 0000000..b05ce35 --- /dev/null +++ b/src/lib/notifications/emailHandler.ts @@ -0,0 +1,8 @@ +import { formatMessage, getMessageTitle } from "./formatter"; +import type { HandlerReturnType, NotificationData, NotificationType, PossibleNotificationType } from "./types"; + +export async function handleEmail<T extends PossibleNotificationType>(recipient: string, type: NotificationType<T>, data: NotificationData<T>): Promise<HandlerReturnType> { + const emailTitle = getMessageTitle(type); + const emailBody = formatMessage(type, data); + return Promise.resolve(); // TODO actually send email +}; diff --git a/src/lib/notifications/formatter.ts b/src/lib/notifications/formatter.ts new file mode 100644 index 0000000..1bb7a2d --- /dev/null +++ b/src/lib/notifications/formatter.ts @@ -0,0 +1,77 @@ +import type { NotificationData, NotificationType, PossibleNotificationType } from "./types"; + +export function getMessageTitle<T extends PossibleNotificationType>(type: NotificationType<T>): string { + switch(type.name){ + case "buy": { + return "Dein Einkauf"; + } + case "refund": { + return "Geld erstattet"; + } + case "deposit": { + return "Geld eingezahlt"; + } + case "withdraw": { + return "Geld ausgezahlt"; + } + case "use-voucher": { + return "Gutschein eingelöst"; + } + case "send-transfer": { + return "Überweisung gesendet"; + } + case "receive-transfer": { + return "Überweisung erhalten"; + } + default: { + console.error(`Unknown notification type`, type); + return `Unknown notification type ${type}`; + } + } +} + +export function formatMessage<T extends PossibleNotificationType>(type: NotificationType<T>, data: NotificationData<T>): string { + switch(type.name){ + case "buy": { + const {total, items, balanceBefore, balanceAfter} = data as NotificationData<"buy">; + const premiums = items.filter(item => item.premium && item.premium > 0).map(item => item.premium).reduce((a,b) => a+b, 0); + return `Du hast folgende ${items.length} Artikel gekauft: +${items.map(item => `- ${item.name} (${item.code}) - ${(item.price/100).toFixed(2)}€` + (item.premium && item.premium > 0 ? ` (+${(item.premium/100).toFixed(2)}€)` : "")).join("\n")} +Gesamt: ${(total/100).toFixed(2)}€` + (premiums > 0 ? ` (davon ${(premiums/100).toFixed(2)}€ Negativaufschlag)` : "") + `\nDein neuer Kontostand beträgt ${(balanceAfter/100).toFixed(2)}€.`; + } + case "refund": { + const {refund, item, balanceBefore, balanceAfter, timeBought} = data as NotificationData<"refund">; + return `Dir wurden ${(refund/100).toFixed(2)}€ für ${item.name} (${item.code}) erstattet, gekauft am ${timeBought.toLocaleString()}. +Dein neuer Kontostand beträgt ${(balanceAfter/100).toFixed(2)}€.`; + } + case "deposit": { + const {amount, balanceBefore, balanceAfter} = data as NotificationData<"deposit">; + return `Du hast ${(amount/100).toFixed(2)}€ eingezahlt. +Dein neuer Kontostand beträgt ${(balanceAfter/100).toFixed(2)}€.`; + } + case "withdraw": { + const {amount, balanceBefore, balanceAfter} = data as NotificationData<"withdraw">; + return `Du hast ${(amount/100).toFixed(2)}€ ausgezahlt. +Dein neuer Kontostand beträgt ${(balanceAfter/100).toFixed(2)}€.`; + } + case "use-voucher": { + const {voucher, balanceBefore, balanceAfter} = data as NotificationData<"use-voucher">; + return `Du hast einen Gutschein im Wert von ${(voucher.value/100).toFixed(2)}€ eingelöst. +Dein neuer Kontostand beträgt ${(balanceAfter/100).toFixed(2)}€.`; + } + case "send-transfer": { + const {amount, balanceBefore, balanceAfter, receiver} = data as NotificationData<"send-transfer">; + return `Du hast ${(amount/100).toFixed(2)}€ an ${receiver.name} überwiesen. +Dein neuer Kontostand beträgt ${(balanceAfter/100).toFixed(2)}€.`; + } + case "receive-transfer": { + const {amount, balanceBefore, balanceAfter, sender} = data as NotificationData<"receive-transfer">; + return `Du hast ${(amount/100).toFixed(2)}€ von ${sender.name} erhalten. +Dein neuer Kontostand beträgt ${(balanceAfter/100).toFixed(2)}€.`; + } + default: { + console.error(`Unknown notification type`, type); + return `Unknown notification type ${type}`; + } + } +} diff --git a/src/lib/notifications/handler.ts b/src/lib/notifications/handler.ts new file mode 100644 index 0000000..780dda9 --- /dev/null +++ b/src/lib/notifications/handler.ts @@ -0,0 +1,38 @@ +import { db } from "$lib/server/database"; +import { ChannelType } from "./channelTypes"; +import type { HandlerReturnType, NotificationChannel, NotificationData, NotificationType, PossibleNotificationType } from "./types"; + +export async function sendNotification<T extends PossibleNotificationType>(user: { id: number, notificationChannels: NotificationChannel[] | undefined } | number, type: NotificationType<T>, data: NotificationData<T>): Promise<HandlerReturnType[]>{ + let notificationChannels: NotificationChannel[]; + let userId: number; + if(typeof user === "number"){ + userId = user; + }else{ + userId = user.id; + notificationChannels = user.notificationChannels?.filter(channel => channel.notificationTypes & type.key); + } + if(!notificationChannels){ + notificationChannels = await getNotificationChannels(userId, type.key); + } + return Promise.all(notificationChannels.map(channel => { + switch(channel.channelType){ + case ChannelType.EMAIL.key: { + return ChannelType.EMAIL.handler(channel.recipient, type, data); + } + case ChannelType.WEBHOOK.key: { + return ChannelType.WEBHOOK.handler(channel.recipient, type, data); + } + default: { + console.error(`Unknown channel type`, channel.channelType); + return Promise.resolve(); + } + }; + })); +}; + +async function getNotificationChannels(userId: number, notificationType: number): Promise<NotificationChannel[]> { + const allChannels = await db.notificationChannel.findMany({ + where: { userId } + }); + return allChannels.filter(channel => channel.notificationTypes & notificationType); +} diff --git a/src/lib/notifications/notificationTypes.js b/src/lib/notifications/notificationTypes.js new file mode 100644 index 0000000..399eab0 --- /dev/null +++ b/src/lib/notifications/notificationTypes.js @@ -0,0 +1,19 @@ +/** @type {{ + * BUY: import("./types").NotificationType<"buy">, + * REFUND: import("./types").NotificationType<"refund">, + * DEPOSIT: import("./types").NotificationType<"deposit">, + * WITHDRAW: import("./types").NotificationType<"withdraw">, + * USE_VOUCHER: import("./types").NotificationType<"use-voucher">, + * SEND_TRANSFER: import("./types").NotificationType<"send-transfer">, + * RECEIVE_TRANSFER: import("./types").NotificationType<"receive-transfer">, + * }} + */ +export const NotificationType = { + BUY: {key: 1<<0, name: "buy"}, + REFUND: {key: 1<<1, name: "refund"}, + DEPOSIT: {key: 1<<2, name: "deposit"}, + WITHDRAW: {key: 1<<3, name: "withdraw"}, + USE_VOUCHER: {key: 1<<4, name: "use-voucher"}, + SEND_TRANSFER: {key: 1<<5, name: "send-transfer"}, + RECEIVE_TRANSFER: {key: 1<<6, name: "receive-transfer"}, +}; diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts new file mode 100644 index 0000000..a7f1eb8 --- /dev/null +++ b/src/lib/notifications/types.ts @@ -0,0 +1,35 @@ +export type PossibleNotificationType = "buy" | "refund" | "deposit" | "withdraw" | "use-voucher" | "send-transfer" | "receive-transfer"; + +export type Item = {name: string, code: string, price: number, premium: number|undefined}; +export type User = {name: string, id: number} + +export type NotificationType<T extends PossibleNotificationType> = {key: number, name: T}; +export type BuyNotificationData = {total: number, items: Item[], balanceBefore: number, balanceAfter: number}; +export type RefundNotificationData = {refund: number, item: Item, balanceBefore: number, balanceAfter: number, timeBought: Date}; +export type DepositNotificationData = {amount: number, balanceBefore: number, balanceAfter: number}; +export type WithdrawNotificationData = {amount: number, balanceBefore: number, balanceAfter: number}; +export type UseVoucherNotificationData = {voucher: {code: string, value: number}, balanceBefore: number, balanceAfter: number}; +export type SendTransferNotificationData = {amount: number, receiver: User, balanceBefore: number, balanceAfter: number}; +export type ReceiveTransferNotificationData = {amount: number, sender: User, balanceBefore: number, balanceAfter: number}; +export type NotificationData<T extends PossibleNotificationType> = + T extends "buy" ? BuyNotificationData : + T extends "refund" ? RefundNotificationData : + T extends "deposit" ? DepositNotificationData : + T extends "withdraw" ? WithdrawNotificationData : + T extends "use-voucher" ? UseVoucherNotificationData : + T extends "send-transfer" ? SendTransferNotificationData : + T extends "receive-transfer" ? ReceiveTransferNotificationData : + never; + +export type HandlerReturnType = any; +export type NotificationHandler<T extends PossibleNotificationType> = (recipient: string, type: NotificationType<T>, data: NotificationData<T>) => Promise<HandlerReturnType>; +export type ChannelType<T extends PossibleNotificationType> = {key: number, handler: NotificationHandler<T>}; + +export type NotificationChannel = { + id: number, + userId: number, + channelType: number, + notificationTypes: number, + recipient: string +}; +export type GenericNotificationHandler<T extends PossibleNotificationType> = (user: {id:number,notificationChannels:NotificationChannel[]|undefined}|number, type: NotificationType<T>, data: NotificationData<T>) => Promise<any>; diff --git a/src/lib/notifications/webhookHandler.ts b/src/lib/notifications/webhookHandler.ts new file mode 100644 index 0000000..7f74298 --- /dev/null +++ b/src/lib/notifications/webhookHandler.ts @@ -0,0 +1,15 @@ +import { formatMessage } from "./formatter"; +import type { HandlerReturnType, NotificationData, NotificationType, PossibleNotificationType } from "./types"; + +export async function handleWebhook<T extends PossibleNotificationType>(recipient: string, type: NotificationType<T>, data: NotificationData<T>): Promise<HandlerReturnType> { + const body = JSON.stringify(Object.assign({}, data, {type: type.name, content: formatMessage(type, data)})); + const headers = { + "Content-Type": "application/json" + }; + console.log({recipient, type, data, body}); + return fetch(recipient, { + method: "POST", + body, + headers + }); +}; -- GitLab