fix(dashboard): improvements to notifications templates

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-12-29 22:48:06 +01:00
parent e5b5a8af62
commit c12eb80867
9 changed files with 149 additions and 59 deletions

View File

@@ -21,17 +21,16 @@ export default function Page({
.withDefault('available') .withDefault('available')
.parseServerSide(searchParams.tab); .parseServerSide(searchParams.tab);
return ( return (
<Padding> <Padding className="col gap-8">
<PageTabs className="mb-4"> <div className="col gap-4">
<PageTabsLink href="?tab=available" isActive={tab === 'available'}> <h2 className="text-3xl font-semibold">Your integrations</h2>
Available <ActiveIntegrations />
</PageTabsLink> </div>
<PageTabsLink href="?tab=installed" isActive={tab === 'installed'}>
Installed <div className="col gap-4">
</PageTabsLink> <h2 className="text-3xl font-semibold">Available integrations</h2>
</PageTabs> <AllIntegrations />
{tab === 'installed' && <ActiveIntegrations />} </div>
{tab === 'available' && <AllIntegrations />}
</Padding> </Padding>
); );
} }

View File

@@ -10,7 +10,7 @@ type WithLabel = {
children: React.ReactNode; children: React.ReactNode;
label: string; label: string;
error?: string | undefined; error?: string | undefined;
info?: string; info?: React.ReactNode;
className?: string; className?: string;
}; };
type InputWithLabelProps = InputProps & Omit<WithLabel, 'children'>; type InputWithLabelProps = InputProps & Omit<WithLabel, 'children'>;

View File

@@ -28,7 +28,7 @@ export function useColumns() {
const { title, isReadAt } = row.original; const { title, isReadAt } = row.original;
return ( return (
<div className="row gap-2 items-center"> <div className="row gap-2 items-center">
{isReadAt === null && <PingBadge>Unread</PingBadge>} {/* {isReadAt === null && <PingBadge>Unread</PingBadge>} */}
<span className="max-w-md truncate font-medium">{title}</span> <span className="max-w-md truncate font-medium">{title}</span>
</div> </div>
); );
@@ -46,6 +46,22 @@ export function useColumns() {
); );
}, },
}, },
{
accessorKey: 'integration',
header: 'Integration',
cell({ row }) {
const integration = row.original.integration;
return <div>{integration?.name}</div>;
},
},
{
accessorKey: 'notificationRule',
header: 'Rule',
cell({ row }) {
const rule = row.original.notificationRule;
return <div>{rule?.name}</div>;
},
},
{ {
accessorKey: 'country', accessorKey: 'country',
header: 'Country', header: 'Country',
@@ -121,16 +137,8 @@ export function useColumns() {
header: 'Created at', header: 'Created at',
cell({ row }) { cell({ row }) {
const date = row.original.createdAt; const date = row.original.createdAt;
const rule = row.original.integration?.notificationRules[0];
return ( return (
<div className="col gap-1"> <div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
{rule && (
<div className="text-sm text-muted-foreground">
Rule: {rule.name}
</div>
)}
</div>
); );
}, },
}, },

View File

@@ -62,7 +62,12 @@ export function Tooltiper({
if (disabled) return children; if (disabled) return children;
return ( return (
<Tooltip delayDuration={delayDuration}> <Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild={asChild} className={className} onClick={onClick}> <TooltipTrigger
asChild={asChild}
className={className}
onClick={onClick}
type="button"
>
{children} {children}
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>

View File

@@ -164,7 +164,48 @@ export default function AddNotificationRule({ rule }: Props) {
<WithLabel <WithLabel
label="Template" label="Template"
info="Customize your notification. Exisiting variables: $EVENT_NAME, $RULE_NAME" info={
<div className="text-base leading-normal max-w-sm p-2 prose text-left text-white [&_code]:text-white">
<p>
Customize your notification message. You can grab any property
from your event.
</p>
<ul>
<li>
<code>{'{{name}}'}</code> - The name of the event
</li>
<li>
<code>{'{{rule_name}}'}</code> - The name of the rule
</li>
<li>
<code>{'{{properties.your.property}}'}</code> - Get the value
of a custom property
</li>
<li>
<div className="flex gap-x-2 flex-wrap">
And many more...
<code>profileId</code>
<code>createdAt</code>
<code>country</code>
<code>city</code>
<code>os</code>
<code>osVersion</code>
<code>browser</code>
<code>browserVersion</code>
<code>device</code>
<code>brand</code>
<code>model</code>
<code>path</code>
<code>origin</code>
<code>referrer</code>
<code>referrerName</code>
<code>referrerType</code>
</div>
</li>
</ul>
</div>
}
> >
<Textarea <Textarea
{...form.register('template')} {...form.register('template')}

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "notifications" ADD COLUMN "notificationRuleId" UUID;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_notificationRuleId_fkey" FOREIGN KEY ("notificationRuleId") REFERENCES "notification_rules"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -380,37 +380,40 @@ enum IntegrationType {
} }
model NotificationRule { model NotificationRule {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String name String
projectId String projectId String
project Project @relation(fields: [projectId], references: [id]) project Project @relation(fields: [projectId], references: [id])
integrations Integration[] integrations Integration[]
sendToApp Boolean @default(false) sendToApp Boolean @default(false)
sendToEmail Boolean @default(false) sendToEmail Boolean @default(false)
/// [IPrismaNotificationRuleConfig] /// [IPrismaNotificationRuleConfig]
config Json config Json
template String? template String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
notifications Notification[]
@@map("notification_rules") @@map("notification_rules")
} }
model Notification { model Notification {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String projectId String
project Project @relation(fields: [projectId], references: [id]) project Project @relation(fields: [projectId], references: [id])
title String title String
message String message String
isReadAt DateTime? isReadAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
sendToApp Boolean @default(false) sendToApp Boolean @default(false)
sendToEmail Boolean @default(false) sendToEmail Boolean @default(false)
integration Integration? @relation(fields: [integrationId], references: [id]) integration Integration? @relation(fields: [integrationId], references: [id])
integrationId String? @db.Uuid integrationId String? @db.Uuid
notificationRuleId String? @db.Uuid
notificationRule NotificationRule? @relation(fields: [notificationRuleId], references: [id])
/// [IPrismaNotificationPayload] /// [IPrismaNotificationPayload]
payload Json? payload Json?
@@map("notifications") @@map("notifications")
} }

View File

@@ -17,7 +17,12 @@ import { getProjectByIdCached } from './project.service';
type ICreateNotification = Pick< type ICreateNotification = Pick<
Notification, Notification,
'projectId' | 'title' | 'message' | 'integrationId' | 'payload' | 'projectId'
| 'title'
| 'message'
| 'integrationId'
| 'payload'
| 'notificationRuleId'
>; >;
export type INotificationPayload = export type INotificationPayload =
@@ -118,6 +123,7 @@ export async function createNotification(notification: ICreateNotification) {
projectId: notification.projectId, projectId: notification.projectId,
payload: notification.payload || Prisma.DbNull, payload: notification.payload || Prisma.DbNull,
...getIntegration(notification.integrationId), ...getIntegration(notification.integrationId),
notificationRuleId: notification.notificationRuleId,
}, },
}); });
@@ -202,9 +208,23 @@ function notificationTemplateEvent({
rule: INotificationRuleCached; rule: INotificationRuleCached;
}) { }) {
if (!rule.template) return `You received a new "${payload.name}" event`; if (!rule.template) return `You received a new "${payload.name}" event`;
return rule.template let template = rule.template
.replaceAll('$EVENT_NAME', payload.name) .replaceAll('$EVENT_NAME', payload.name)
.replaceAll('$RULE_NAME', rule.name); .replaceAll('$RULE_NAME', rule.name)
.replaceAll('{{rule_name}}', rule.name);
// Replace all {{xxx}} placeholders with their values
const placeholderMatches = template.match(/{{[^}]+}}/g) || [];
for (const match of placeholderMatches) {
const path = match.slice(2, -2); // Remove {{ and }}
const value = pathOr('', path.split('.'), payload);
if (value) {
template = template.replaceAll(match, JSON.stringify(value));
}
}
return template;
} }
function notificationTemplateFunnel({ function notificationTemplateFunnel({
@@ -253,6 +273,7 @@ export async function checkNotificationRulesForEvent(
createNotification({ createNotification({
...notification, ...notification,
integrationId: integration.id, integrationId: integration.id,
notificationRuleId: rule.id,
}), }),
); );
@@ -261,6 +282,7 @@ export async function checkNotificationRulesForEvent(
createNotification({ createNotification({
...notification, ...notification,
integrationId: APP_NOTIFICATION_INTEGRATION_ID, integrationId: APP_NOTIFICATION_INTEGRATION_ID,
notificationRuleId: rule.id,
}), }),
); );
} }
@@ -270,6 +292,7 @@ export async function checkNotificationRulesForEvent(
createNotification({ createNotification({
...notification, ...notification,
integrationId: EMAIL_NOTIFICATION_INTEGRATION_ID, integrationId: EMAIL_NOTIFICATION_INTEGRATION_ID,
notificationRuleId: rule.id,
}), }),
); );
} }
@@ -325,13 +348,18 @@ export async function checkNotificationRulesForSessionEnd(
// Generate notification promises // Generate notification promises
return [ return [
...rule.integrations.map((integration) => ...rule.integrations.map((integration) =>
createNotification({ ...notification, integrationId: integration.id }), createNotification({
...notification,
integrationId: integration.id,
notificationRuleId: rule.id,
}),
), ),
...(rule.sendToApp ...(rule.sendToApp
? [ ? [
createNotification({ createNotification({
...notification, ...notification,
integrationId: APP_NOTIFICATION_INTEGRATION_ID, integrationId: APP_NOTIFICATION_INTEGRATION_ID,
notificationRuleId: rule.id,
}), }),
] ]
: []), : []),
@@ -340,6 +368,7 @@ export async function checkNotificationRulesForSessionEnd(
createNotification({ createNotification({
...notification, ...notification,
integrationId: EMAIL_NOTIFICATION_INTEGRATION_ID, integrationId: EMAIL_NOTIFICATION_INTEGRATION_ID,
notificationRuleId: rule.id,
}), }),
] ]
: []), : []),

View File

@@ -21,19 +21,19 @@ export const notificationRouter = createTRPCRouter({
return db.notification.findMany({ return db.notification.findMany({
where: { where: {
projectId: input.projectId, projectId: input.projectId,
sendToApp: true,
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },
include: { include: {
integration: { integration: {
include: { select: {
notificationRules: { name: true,
select: { },
name: true, },
}, notificationRule: {
}, select: {
name: true,
}, },
}, },
}, },