Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
95be1c1
feat: initial websocket implementation
nahSystemu Nov 17, 2025
32ff561
feat: websocket events for boards & cards
nahSystemu Nov 22, 2025
fdfc5ea
feat(api): add label.changed, board.updated/deleted, attachment.chang…
nahSystemu May 1, 2026
a081e1e
feat(db): expose workspacePublicId and boardId in label/board/attachm…
nahSystemu May 1, 2026
b24c678
feat(api): publish real-time events from board, label, attachment, an…
nahSystemu May 1, 2026
2029b23
feat(web): handle label.changed, board.updated/deleted, attachment.ch…
nahSystemu May 1, 2026
c481dc1
refactor(websocket): replace local logger with @kan/logger, inline ro…
nahSystemu May 1, 2026
129d2c8
feat(websocket): add /health endpoint, idempotent shutdown, 0.0.0.0 b…
nahSystemu May 1, 2026
5f97a66
feat(websocket): harden ingest — body size cap, timing-safe secret, x…
nahSystemu May 1, 2026
932f65e
build(websocket): add Dockerfile, smoke test script, and scripts tsco…
nahSystemu May 1, 2026
4916111
chore: add websocket service to docker-compose, env vars to .env.exam…
nahSystemu May 1, 2026
9064d07
docs: add WebSocket server section to README
nahSystemu May 1, 2026
b61b5e7
chore: update pnpm lockfile
nahSystemu May 1, 2026
7b5c1bc
fix(api): rename WEBSOCKET_EVENT_URL to WEBSOCKET_INGEST_URL in publi…
nahSystemu May 1, 2026
7cf20cc
feat(web): invalidate all queries on websocket subscription start/rec…
nahSystemu May 1, 2026
9886574
feat(api/web): add actorUserId to events for own-mutation echo suppre…
nahSystemu May 1, 2026
1e9ed9e
feat(api/web): add board.created event type and emit on board creation
nahSystemu May 1, 2026
13d0ef3
feat(api/web): emit member invited/removed/role-changed events
nahSystemu May 1, 2026
09984ad
refactor(api): replace console.error/warn with @kan/logger in event e…
nahSystemu May 1, 2026
5ed4a82
feat(api/web): add in-app notification inbox with websocket push
nahSystemu May 1, 2026
176ac45
feat(web): add websocket connection status indicator
nahSystemu May 1, 2026
07abb1e
feat(api): expand webhook coverage for board, list, label, comment ev…
nahSystemu May 1, 2026
634965d
feat(api): add rate limiting to high-frequency card mutations
nahSystemu May 1, 2026
165116c
feat(api): add Redis-backed pub/sub with EventEmitter fallback
nahSystemu May 1, 2026
c4aa855
feat(web): queue mutations offline and invalidate cache on reconnect
nahSystemu May 1, 2026
7466974
feat(api/web): add per-board presence with live viewer avatar stack
nahSystemu May 1, 2026
4581fe6
feat(api): add board-level auth and event filter to board/card subscr…
nahSystemu May 1, 2026
480069b
fix(docker): resolve all build errors for websocket and web images
nahSystemu May 1, 2026
11c75cb
fix: notification icon
nahSystemu May 1, 2026
4eefe5b
fix: websocket connection from using deprecated
nahSystemu May 1, 2026
c3bbec6
fix: env issue no variable but still trying to find it
nahSystemu May 1, 2026
924060b
fix: notifications area for sockets
nahSystemu May 1, 2026
4ca78a9
fix: appearance of different elements
nahSystemu May 1, 2026
2f28c5d
fix: use runtime env instead of build
nahSystemu May 1, 2026
2dbcaba
chore: translation placeholders
nahSystemu May 1, 2026
cd9eba0
fix: comments not being invalidated
nahSystemu May 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# Required environment variables
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
NEXT_PUBLIC_WEBSOCKET_URL= # e.g. wss://ws.kan.bn
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 26 | tr -dc 'a-zA-Z0-9' | head -c 32)

# Fill if you want to use an external database
Expand Down Expand Up @@ -95,3 +96,14 @@ TWITCH_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
APPLE_APP_BUNDLE_IDENTIFIER=

# WebSocket server (apps/websocket)
WEBSOCKET_PORT=3010
WEBSOCKET_HOST=0.0.0.0
WEBSOCKET_PING_MS=30000
WEBSOCKET_PONG_TIMEOUT_MS=5000
WEBSOCKET_EVENT_PATH=/internal/events
WEBSOCKET_EVENT_SECRET=change-me-in-production

# Web app → WebSocket integration
WEBSOCKET_INGEST_URL=http://localhost:3010/internal/events
122 changes: 78 additions & 44 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/web/src/assets/bell-dark.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/web/src/assets/bell-light.json

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions apps/web/src/components/ConnectionStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from "react";
import { useLingui } from "@lingui/react";
import { msg } from "@lingui/core/macro";

import { useWsConnectionState } from "~/hooks/useWsConnectionState";

const MAX_VISIBLE_ATTEMPTS = 5;

export default function ConnectionStatus() {
const { _ } = useLingui();
const state = useWsConnectionState();
const wasConnected = useRef(false);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const prevState = useRef(state);

useEffect(() => {
if (state === "pending") {
wasConnected.current = true;
// Reset counter on successful reconnect
setReconnectAttempts(0);
}

// Each time we drop back to "connecting" after being connected = one attempt
if (
state === "connecting" &&
wasConnected.current &&
prevState.current !== "connecting"
) {
setReconnectAttempts((n) => n + 1);
}

prevState.current = state;
}, [state]);

const isReconnecting = state === "connecting" && wasConnected.current;

// After MAX_VISIBLE_ATTEMPTS, hide the banner but keep reconnecting silently
if (!isReconnecting || reconnectAttempts > MAX_VISIBLE_ATTEMPTS) return null;

return (
<div className="pointer-events-none fixed bottom-12 left-1/2 z-50 -translate-x-1/2">
<div className="flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2 shadow-md dark:border-dark-300 dark:bg-dark-100">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-indigo-500 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-indigo-600" />
</span>
<span className="text-xs text-neutral-600 dark:text-dark-800">
{_(msg`Reconnecting…`)}
</span>
</div>
</div>
);
}
11 changes: 8 additions & 3 deletions apps/web/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import { authClient } from "@kan/auth/client";

import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal";
import { EventsProvider } from "~/providers/events";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api";
import SideNavigation from "./SideNavigation";
import ConnectionStatus from "./ConnectionStatus";

interface DashboardProps {
children: React.ReactNode;
Expand All @@ -30,9 +32,11 @@ export function getDashboardLayout(
) {
return (
<WorkspaceProvider>
<Dashboard rightPanel={rightPanel} hasRightPanel={hasRightPanel}>
{page}
</Dashboard>
<EventsProvider>
<Dashboard rightPanel={rightPanel} hasRightPanel={hasRightPanel}>
{page}
</Dashboard>
</EventsProvider>
</WorkspaceProvider>
);
}
Expand Down Expand Up @@ -122,6 +126,7 @@ export default function Dashboard({
}
`}</style>
<div className="relative flex h-screen flex-col bg-light-50 dark:bg-dark-50 md:bg-light-100 md:p-3 md:dark:bg-dark-100">
<ConnectionStatus />
{/* Mobile Header */}
<div className="flex h-12 items-center justify-between border-b border-light-300 bg-light-50 px-3 dark:border-dark-300 dark:bg-dark-50 md:hidden">
<button
Expand Down
243 changes: 243 additions & 0 deletions apps/web/src/components/NotificationBell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { useRouter } from "next/router";
import { Popover, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { formatDistanceToNow } from "date-fns";
import { Fragment, useRef, useState } from "react";
import { HiAtSymbol, HiBell, HiChatBubbleLeft, HiCheckCircle, HiPencil, HiUserGroup, HiUserPlus } from "react-icons/hi2";
import { useTheme } from "next-themes";
import { twMerge } from "tailwind-merge";

import type { NotificationType } from "@kan/db/schema";

import bellDark from "~/assets/bell-dark.json";
import bellLight from "~/assets/bell-light.json";
import LottieIcon from "~/components/LottieIcon";
import { api } from "~/utils/api";

interface NotificationBellProps {
isCollapsed?: boolean;
}

function getNotificationMessage(
type: NotificationType,
cardTitle?: string | null,
workspaceName?: string | null,
): string {
switch (type) {
case "mention":
return cardTitle
? `${t`You were mentioned in`} "${cardTitle}"`
: t`You were mentioned in a card`;
case "workspace.member.added":
return workspaceName
? `${t`You were added to`} "${workspaceName}"`
: t`You were added to a workspace`;
case "workspace.member.removed":
return workspaceName
? `${t`You were removed from`} "${workspaceName}"`
: t`You were removed from a workspace`;
case "workspace.role.changed":
return workspaceName
? `${t`Your role changed in`} "${workspaceName}"`
: t`Your role was changed`;
case "card.member.assigned":
return cardTitle
? `${t`You were assigned to`} "${cardTitle}"`
: t`You were assigned to a card`;
case "card.comment.added":
return cardTitle
? `${t`New comment on`} "${cardTitle}"`
: t`New comment on a card`;
case "card.updated":
return cardTitle
? `${t`Card updated`}: "${cardTitle}"`
: t`A card was updated`;
default:
return t`New notification`;
}
}

function NotificationTypeIcon({ type }: { type: NotificationType }) {
const cls = "text-indigo-500 dark:text-indigo-400";
switch (type) {
case "mention":
return <HiAtSymbol size={12} className={cls} />;
case "card.comment.added":
return <HiChatBubbleLeft size={12} className={cls} />;
case "card.member.assigned":
return <HiUserPlus size={12} className={cls} />;
case "card.updated":
return <HiPencil size={12} className={cls} />;
case "workspace.member.added":
case "workspace.member.removed":
case "workspace.role.changed":
return <HiUserGroup size={12} className={cls} />;
default:
return <HiBell size={12} className={cls} />;
}
}

export default function NotificationBell({ isCollapsed = false }: NotificationBellProps) {
const router = useRouter();
const buttonRef = useRef<HTMLButtonElement>(null);
const utils = api.useUtils();
const [isHovered, setIsHovered] = useState(false);
const [lottieIndex, setLottieIndex] = useState(0);
const { resolvedTheme } = useTheme();
const isDarkMode = resolvedTheme === "dark";

const { data: unreadData } = api.notification.unreadCount.useQuery(undefined, {
refetchInterval: 60_000,
});

const unreadCount = unreadData?.count ?? 0;

const markAllRead = api.notification.markAllRead.useMutation({
onSuccess: () => {
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});

const markRead = api.notification.markRead.useMutation({
onSuccess: () => {
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});

return (
<Popover className="relative w-full">
{() => (
<>
<Popover.Button
ref={buttonRef}
className={twMerge(
"flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
isCollapsed && "justify-center",
)}
title={isCollapsed ? t`Notifications` : undefined}
onMouseEnter={() => { setIsHovered(true); setLottieIndex((i) => i + 1); }}
>
<div className="relative flex-shrink-0">
{unreadCount > 0 && (
<span className="absolute -left-1 -top-1 flex">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-indigo-500 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-indigo-600" />
</span>
)}
<LottieIcon index={lottieIndex} json={isDarkMode ? bellDark : bellLight} isPlaying={isHovered} />
</div>
{!isCollapsed && (
<span className="ml-2.5 flex-1 text-left text-sm font-medium">{t`Notifications`}</span>
)}
</Popover.Button>

<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Popover.Panel className="absolute bottom-full left-0 z-50 mb-2 w-80 rounded-lg border border-light-300 bg-white shadow-lg dark:border-dark-300 dark:bg-dark-100">
<div className="flex items-center justify-between border-b border-light-300 px-4 py-3 dark:border-dark-300">
<span className="text-sm font-semibold text-neutral-900 dark:text-dark-1000">
{t`Notifications`}
</span>
{unreadCount > 0 && (
<button
onClick={() => markAllRead.mutate()}
className="flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
>
<HiCheckCircle size={14} />
{t`Mark all as read`}
</button>
)}
</div>
<NotificationPanelContent markRead={markRead} router={router} />
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
}

function NotificationPanelContent({
markRead,
router,
}: {
markRead: ReturnType<typeof api.notification.markRead.useMutation>;
router: ReturnType<typeof useRouter>;
}) {
const { data: notifications, isLoading } = api.notification.list.useQuery({
limit: 20,
offset: 0,
});

if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-indigo-600 border-t-transparent" />
</div>
);
}

if (!notifications?.length) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<HiBell size={24} className="mb-2 text-light-600 dark:text-dark-600" />
<p className="text-sm text-light-700 dark:text-dark-700">{t`No notifications yet`}</p>
</div>
);
}

const handleNotificationClick = (notification: (typeof notifications)[number]) => {
if (!notification.readAt) {
markRead.mutate({ notificationPublicId: notification.publicId });
}
if (notification.card?.publicId) {
void router.push(`/cards/${notification.card.publicId}`);
}
};

return (
<ul className="max-h-96 overflow-y-auto py-1">
{notifications.map((notification) => (
<li key={notification.publicId}>
<button
onClick={() => handleNotificationClick(notification)}
className={twMerge(
"flex w-full items-start gap-3 border-l-2 px-4 py-3 text-left transition-colors hover:bg-light-200 dark:hover:bg-dark-300",
!notification.readAt
? "border-l-indigo-600 bg-light-100 dark:bg-dark-200"
: "border-l-transparent",
)}
>
<div className="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-light-200 dark:bg-dark-300">
<NotificationTypeIcon type={notification.type} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-neutral-900 dark:text-dark-1000">
{getNotificationMessage(
notification.type,
notification.card?.title,
notification.workspace?.name,
)}
</p>
<p className="mt-0.5 text-xs text-light-800 dark:text-dark-800">
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
</p>
</div>
{!notification.readAt && (
<span className="mt-2 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-indigo-600" />
)}
</button>
</li>
))}
</ul>
);
}
2 changes: 2 additions & 0 deletions apps/web/src/components/SideNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import settingsIconLight from "~/assets/settings-light.json";
import templatesIconDark from "~/assets/templates-dark.json";
import templatesIconLight from "~/assets/templates-light.json";
import ButtonComponent from "~/components/Button";
import NotificationBell from "~/components/NotificationBell";
import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu";
Expand Down Expand Up @@ -207,6 +208,7 @@ export default function SideNavigation({
</div>

<div className="space-y-2">
<NotificationBell isCollapsed={isCollapsed} />
<UserMenu
displayName={user.displayName ?? undefined}
email={user.email ?? "Email not provided?"}
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export const env = createEnv({
LINKEDIN_CLIENT_SECRET: z.string().optional(),
NOVU_API_KEY: z.string().optional(),
EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(),
VERCEL_URL: z.string().optional(),
PORT: z.string().optional(),
// Generic OIDC Provider
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
Expand Down Expand Up @@ -92,7 +94,8 @@ export const env = createEnv({
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string().url().optional(),
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
NEXT_PUBLIC_WEBSOCKET_URL: z.string().url().optional(),
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional().or(z.literal("")),
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
Expand Down Expand Up @@ -150,6 +153,7 @@ export const env = createEnv({
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY:
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY,
NEXT_PUBLIC_WEBSOCKET_URL: process.env.NEXT_PUBLIC_WEBSOCKET_URL,
},
skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
Expand Down
Loading