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 0000000000000000000000000000000000000000..ef3b648b59b55fc0d4b3d12a32a531461291ad51 --- /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 00f3dfb564b889358834f21a33decaa55c2ab6e2..9f37714a220ae0d639b291d4cf884b24dfb780ee 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 0000000000000000000000000000000000000000..ffbf120472a1eed547104364d6177124199144d9 --- /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 0000000000000000000000000000000000000000..b05ce35126786325d2d2acb2531a6ac838b36e3d --- /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 0000000000000000000000000000000000000000..1bb7a2d78e221005849bc8955629cfa392a34b94 --- /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 0000000000000000000000000000000000000000..780dda94901fe3f7c0ef4130babf3093bd4e6cd0 --- /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 0000000000000000000000000000000000000000..399eab045e4ede0eaac44bf2e1610175c97410c9 --- /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 0000000000000000000000000000000000000000..a7f1eb858a5bf9ab4492e03e41d67d9b459c3c15 --- /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 0000000000000000000000000000000000000000..7f7429860028c84bb18e192320102812f2cdfeb7 --- /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 + }); +};