Taskr provides a unified notification system that routes alerts across
email, in-app notifications, and Slack based on event type and user preferences.
Notification Channels
Transactional and digest emails via Resend
In-App
Real-time dashboard notifications
Slack
Team channel and DM notifications
Architecture
Event Types
Job Events
| Event | Default Channels | Priority |
|---|---|---|
job.created | In-App | Normal |
job.assigned | Email, In-App | High |
job.started | In-App | Normal |
job.completed | Email, In-App, Slack | High |
job.failed | Email, In-App, Slack | Urgent |
job.overdue | Email, In-App | High |
Invoice Events
| Event | Default Channels | Priority |
|---|---|---|
invoice.created | In-App | Normal |
invoice.sent | Email (customer), In-App | Normal |
invoice.paid | Email, In-App, Slack | High |
invoice.overdue | Email, In-App | High |
invoice.reminder_sent | In-App | Low |
Quote Events
| Event | Default Channels | Priority |
|---|---|---|
quote.created | In-App | Normal |
quote.sent | In-App | Normal |
quote.viewed | In-App | Normal |
quote.accepted | Email, In-App, Slack | High |
quote.rejected | Email, In-App | Normal |
quote.expired | Email, In-App | Normal |
Bank Events
| Event | Default Channels | Priority |
|---|---|---|
bank.transactions_synced | In-App | Low |
bank.payment_received | In-App, Slack | Normal |
bank.large_transaction | Email, In-App | High |
bank.connection_error | Email, In-App | Urgent |
Personnel Events
| Event | Default Channels | Priority |
|---|---|---|
member.joined | In-App, Slack | Normal |
member.invited | High | |
member.role_changed | Email, In-App | Normal |
member.offboarded | Email, In-App | Normal |
Notification Flow
1
Event Triggered
Business action triggers notification event:
// After job completion
await triggerNotification({
type: "job.completed",
teamId,
data: {
jobId: job.id,
jobNumber: job.number,
customerName: job.customer.name,
completedBy: technician.name,
},
recipients: {
roles: ["owner", "admin"],
userIds: [job.assignedTo],
},
});
2
Check Preferences
System checks each recipient’s notification preferences:
const preferences = await getUserNotificationPreferences(userId, teamId);
// Example preferences structure
// {
// email: { enabled: true, digest: "daily" },
// inApp: { enabled: true },
// slack: { enabled: true, dmOnly: false },
// muted: ["invoice.reminder_sent"],
// }
3
Route to Channels
Based on preferences and priority, route to appropriate channels:
const channels = determineChannels(event, preferences, priority);
// High priority events always send email
// Muted events skip entirely
// Digest mode batches low-priority emails
4
Deliver Notifications
Send to each enabled channel:
// Email
if (channels.includes("email")) {
await queueEmail({
to: user.email,
template: event.type,
data: event.data,
priority,
});
}
// In-App
if (channels.includes("inApp")) {
await createNotification({
userId,
teamId,
type: event.type,
title: formatTitle(event),
message: formatMessage(event),
data: event.data,
priority,
read: false,
});
}
// Slack
if (channels.includes("slack")) {
await sendSlackNotification({
webhookUrl: team.slackWebhookUrl,
channel: team.slackChannel,
message: formatSlackMessage(event),
});
}
Email Notifications
Email Templates
// packages/workbooks-email/emails/index.ts
export { default as JobAssignedEmail } from "./job-assigned";
export { default as JobCompletedEmail } from "./job-completed";
export { default as QuoteAcceptedEmail } from "./quote-accepted";
export { default as InvoicePaidEmail } from "./invoice-paid";
export { default as TrialExpiringEmail } from "./trial-expiring";
export { default as TeamInvitationEmail } from "./team-invitation";
export { default as WelcomeEmail } from "./welcome";
// ... more templates
Sending Email
// Using Resend provider
import { Resend } from "resend";
import { JobCompletedEmail } from "@repo/workbooks-email/emails";
const resend = new Resend(env.RESEND_API_KEY);
await resend.emails.send({
from: "Taskr <[email protected]>",
to: recipient.email,
subject: `Job #${job.number} completed`,
react: JobCompletedEmail({
jobNumber: job.number,
customerName: job.customer.name,
completedAt: job.completedAt,
viewUrl: `${baseUrl}/jobs/${job.id}`,
}),
});
Email Digest
Low-priority notifications can be batched into a daily digest:// Daily digest job runs at 8 AM local time
export const sendDailyDigest = task({
id: "send-daily-digest",
schedule: "0 8 * * *",
}, async () => {
const users = await getUsersWithDigestEnabled();
for (const user of users) {
const notifications = await getUnsentDigestNotifications(user.id);
if (notifications.length > 0) {
await sendEmail({
to: user.email,
subject: `Your daily summary - ${notifications.length} updates`,
react: DailyDigestEmail({ notifications }),
});
await markNotificationsDigested(notifications);
}
}
});
In-App Notifications
Creating Notifications
// packages/workbooks-jobs/src/tasks/notifications/create-notification.ts
interface CreateNotificationParams {
userId: string;
teamId: string;
type: NotificationType;
title: string;
message: string;
data?: Record<string, any>;
priority: "low" | "normal" | "high" | "urgent";
actionUrl?: string;
}
export async function createNotification(params: CreateNotificationParams) {
const notification = await db.insert(notifications).values({
...params,
read: false,
createdAt: new Date().toISOString(),
}).returning();
// Broadcast to connected WebSocket clients
await broadcastToUser(params.userId, {
type: "notification:new",
notification,
});
return notification;
}
Notification UI
// apps/workbooks-dashboard/src/components/notifications/notification-bell.tsx
export function NotificationBell() {
const trpc = useTRPC();
const { data: notifications } = useQuery(
trpc.notifications.getUnread.queryOptions()
);
const unreadCount = notifications?.length ?? 0;
return (
<Popover>
<PopoverTrigger>
<Button variant="ghost" size="icon" className="relative">
<BellIcon />
{unreadCount > 0 && (
<Badge className="absolute -top-1 -right-1">
{unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent>
<NotificationList notifications={notifications} />
</PopoverContent>
</Popover>
);
}
Marking as Read
// Mark single notification as read
await trpc.notifications.markRead.mutate({ id: notificationId });
// Mark all as read
await trpc.notifications.markAllRead.mutate();
// Mark as read when clicked
const handleNotificationClick = async (notification: Notification) => {
await markRead({ id: notification.id });
router.push(notification.actionUrl);
};
Slack Integration
Configuration
// Team Slack settings
interface SlackConfig {
enabled: boolean;
webhookUrl: string;
defaultChannel: string;
channelOverrides: {
[eventType: string]: string;
};
dmEnabled: boolean;
userMappings: {
[userId: string]: string; // Slack user ID
};
}
Sending Slack Messages
// packages/workbooks-app-store/src/slack/lib/notifications/transactions.ts
export async function sendSlackNotification({
webhookUrl,
channel,
message,
blocks,
}: SlackNotificationParams) {
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
channel,
text: message,
blocks: blocks || [
{
type: "section",
text: { type: "mrkdwn", text: message },
},
],
}),
});
}
Rich Message Formatting
// Format job completion for Slack
const formatJobCompletedSlack = (job: Job) => ({
blocks: [
{
type: "header",
text: { type: "plain_text", text: `Job #${job.number} Completed` },
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Customer:*\n${job.customer.name}` },
{ type: "mrkdwn", text: `*Technician:*\n${job.assignee.name}` },
{ type: "mrkdwn", text: `*Location:*\n${job.location.address}` },
{ type: "mrkdwn", text: `*Duration:*\n${formatDuration(job.duration)}` },
],
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "View Job" },
url: `${baseUrl}/jobs/${job.id}`,
},
],
},
],
});
User Preferences
Preference Schema
export const notificationPreferences = pgTable("notification_preferences", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => users.id),
teamId: uuid("team_id").references(() => teams.id),
// Channel settings
emailEnabled: boolean("email_enabled").default(true),
emailDigest: text("email_digest"), // immediate, daily, weekly, none
inAppEnabled: boolean("in_app_enabled").default(true),
slackEnabled: boolean("slack_enabled").default(true),
slackDmOnly: boolean("slack_dm_only").default(false),
// Event-specific overrides
mutedEvents: jsonb("muted_events").$type<string[]>(),
channelOverrides: jsonb("channel_overrides").$type<Record<string, string[]>>(),
// Quiet hours
quietHoursEnabled: boolean("quiet_hours_enabled").default(false),
quietHoursStart: text("quiet_hours_start"), // "22:00"
quietHoursEnd: text("quiet_hours_end"), // "08:00"
timezone: text("timezone").default("UTC"),
});
Preference UI
// apps/workbooks-dashboard/src/app/(app)/settings/notifications/page.tsx
export default function NotificationSettings() {
const [preferences, setPreferences] = useState<NotificationPreferences>();
return (
<SettingsLayout title="Notification Preferences">
<Section title="Email Notifications">
<Switch
label="Enable email notifications"
checked={preferences?.emailEnabled}
onCheckedChange={(v) => updatePreference("emailEnabled", v)}
/>
<Select
label="Email frequency"
value={preferences?.emailDigest}
options={[
{ value: "immediate", label: "Send immediately" },
{ value: "daily", label: "Daily digest" },
{ value: "weekly", label: "Weekly summary" },
]}
/>
</Section>
<Section title="Quiet Hours">
<Switch
label="Enable quiet hours"
checked={preferences?.quietHoursEnabled}
/>
<TimeRangePicker
start={preferences?.quietHoursStart}
end={preferences?.quietHoursEnd}
/>
</Section>
<Section title="Event Settings">
<NotificationEventList
events={eventTypes}
preferences={preferences}
onMute={handleMuteEvent}
onChannelChange={handleChannelChange}
/>
</Section>
</SettingsLayout>
);
}
Priority Handling
| Priority | In-App | Slack | Bypass Quiet Hours | |
|---|---|---|---|---|
| Low | Digest only | Yes | No | No |
| Normal | Based on pref | Yes | Based on pref | No |
| High | Always | Yes | Yes | No |
| Urgent | Always | Yes | Yes | Yes |
Database Schema
// Notifications table
export const notifications = pgTable("notifications", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => users.id),
teamId: uuid("team_id").references(() => teams.id),
// Content
type: text("type").notNull(), // event type
title: text("title").notNull(),
message: text("message"),
data: jsonb("data"),
actionUrl: text("action_url"),
// Status
priority: text("priority").notNull(), // low, normal, high, urgent
read: boolean("read").default(false),
readAt: timestamp("read_at"),
// Delivery tracking
emailSent: boolean("email_sent").default(false),
emailSentAt: timestamp("email_sent_at"),
slackSent: boolean("slack_sent").default(false),
slackSentAt: timestamp("slack_sent_at"),
createdAt: timestamp("created_at").defaultNow(),
});
API Endpoints
| Endpoint | Method | Description |
|---|---|---|
notifications.list | Query | List notifications (paginated) |
notifications.getUnread | Query | Get unread notifications |
notifications.markRead | Mutation | Mark notification as read |
notifications.markAllRead | Mutation | Mark all as read |
notifications.updatePreferences | Mutation | Update preferences |
notifications.getPreferences | Query | Get user preferences |
notifications.muteEvent | Mutation | Mute specific event type |
Related Documentation
Personnel Lifecycle
Team member notification events
Job Scheduler
Job-related notifications