img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+};
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..6a7c5dc
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,19 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx
new file mode 100644
index 0000000..f4bec04
--- /dev/null
+++ b/src/components/ui/progress.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import * as React from "react";
+import { Progress as ProgressPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Progress({
+ className,
+ value,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+
+
+ );
+}
+
+export { Progress };
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..4ba6cbd
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import * as React from "react";
+import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { ScrollArea, ScrollBar };
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..4f7b71c
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import * as React from "react";
+import { Select as SelectPrimitive } from "radix-ui";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default";
+}) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..e779cc3
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import * as React from "react";
+import { Separator as SeparatorPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..26bc059
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils";
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..ef1b517
--- /dev/null
+++ b/src/components/ui/sonner.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useTheme } from "next-themes";
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+import { Toaster as Sonner, type ToasterProps } from "sonner";
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme();
+
+ return (
+ ,
+ info: ,
+ warning: ,
+ error: ,
+ loading: ,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ );
+};
+
+export { Toaster };
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..925485e
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Tabs as TabsPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ );
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..766f78a
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ );
+}
+
+export { Textarea };
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..733073f
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import * as React from "react";
+import { Tooltip as TooltipPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
diff --git a/src/lib/contracts.ts b/src/lib/contracts.ts
new file mode 100644
index 0000000..8b8762c
--- /dev/null
+++ b/src/lib/contracts.ts
@@ -0,0 +1,148 @@
+import { z } from "zod";
+
+export const ALL_CHANNELS_TOPIC = "__all__";
+
+export function normalizeChannel(value: string) {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 60);
+}
+
+const rawChannelSchema = z.string().trim().min(1).max(60);
+const isoDateSchema = z.string().datetime({ offset: true });
+
+export const channelSchema = rawChannelSchema
+ .transform(normalizeChannel)
+ .refine((value) => value.length > 0, {
+ message: "The channel must contain alphanumeric characters.",
+ });
+
+export const prioritySchema = z.enum(["low", "medium", "high", "critical"]);
+
+export const notificationInputSchema = z.object({
+ channel: channelSchema,
+ title: z.string().trim().min(1).max(120),
+ message: z.string().trim().min(1).max(600),
+ priority: prioritySchema,
+});
+
+export const notificationFiltersSchema = z
+ .object({
+ channel: channelSchema.optional(),
+ priority: prioritySchema.optional(),
+ from: isoDateSchema.optional(),
+ to: isoDateSchema.optional(),
+ limit: z.coerce.number().int().min(1).max(100).default(20),
+ offset: z.coerce.number().int().min(0).default(0),
+ })
+ .refine(
+ (value) => {
+ if (!value.from || !value.to) {
+ return true;
+ }
+
+ return new Date(value.from) <= new Date(value.to);
+ },
+ {
+ message: "The date range is invalid.",
+ path: ["to"],
+ }
+ );
+
+export const notificationRecordSchema = z.object({
+ id: z.number().int().positive(),
+ channel: z.string(),
+ title: z.string(),
+ message: z.string(),
+ priority: prioritySchema,
+ timestamp: isoDateSchema,
+});
+
+export const channelSummarySchema = z.object({
+ channel: z.string(),
+ notificationCount: z.number().int().nonnegative(),
+ lastNotificationAt: isoDateSchema.nullable(),
+});
+
+const subscribableChannelSchema = z.union([
+ z.literal(ALL_CHANNELS_TOPIC),
+ channelSchema,
+]);
+
+export const subscribeMessageSchema = z.object({
+ type: z.literal("subscribe"),
+ channels: z.array(subscribableChannelSchema).min(1).max(25),
+});
+
+export const unsubscribeMessageSchema = z.object({
+ type: z.literal("unsubscribe"),
+ channels: z.array(subscribableChannelSchema).min(1).max(25),
+});
+
+export const websocketMessageSchema = z.discriminatedUnion("type", [
+ subscribeMessageSchema,
+ unsubscribeMessageSchema,
+]);
+
+export const notificationEventSchema = z.object({
+ type: z.literal("notification.created"),
+ data: notificationRecordSchema,
+});
+
+export type NotificationPriority = z.infer;
+export type NotificationInput = z.infer;
+export type NotificationFilters = z.infer;
+export type NotificationRecord = z.infer;
+export type ChannelSummary = z.infer;
+export type WebsocketMessage = z.infer;
+export type NotificationEvent = z.infer;
+
+export type ApiErrorPayload = {
+ code: string;
+ message: string;
+ details?: unknown;
+};
+
+export type ApiResponse =
+ | {
+ success: true;
+ data: T;
+ meta?: Record;
+ }
+ | {
+ success: false;
+ error: ApiErrorPayload;
+ };
+
+export function buildNotificationSearchParams(filters: Partial) {
+ const params = new URLSearchParams();
+
+ if (filters.channel) {
+ params.set("channel", filters.channel);
+ }
+
+ if (filters.priority) {
+ params.set("priority", filters.priority);
+ }
+
+ if (filters.from) {
+ params.set("from", filters.from);
+ }
+
+ if (filters.to) {
+ params.set("to", filters.to);
+ }
+
+ if (typeof filters.limit === "number") {
+ params.set("limit", String(filters.limit));
+ }
+
+ if (typeof filters.offset === "number") {
+ params.set("offset", String(filters.offset));
+ }
+
+ return params.toString();
+}
diff --git a/src/lib/notification-service.ts b/src/lib/notification-service.ts
new file mode 100644
index 0000000..92e1db3
--- /dev/null
+++ b/src/lib/notification-service.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from "next/server";
+
+import { fail } from "@/lib/response";
+
+const defaultNotificationServiceUrl = "http://localhost:4001";
+
+function buildServiceUrl(path: string) {
+ const baseUrl =
+ process.env.NOTIFICATION_SERVICE_URL ?? defaultNotificationServiceUrl;
+
+ return new URL(path, baseUrl);
+}
+
+export async function relayNotificationService(
+ path: string,
+ init?: RequestInit
+) {
+ try {
+ const response = await fetch(buildServiceUrl(path), {
+ ...init,
+ cache: "no-store",
+ headers: {
+ Accept: "application/json",
+ ...(init?.body ? { "Content-Type": "application/json" } : {}),
+ ...init?.headers,
+ },
+ });
+
+ const payload = await response
+ .json()
+ .catch(() =>
+ fail(
+ "invalid_service_response",
+ "The internal service returned an invalid response."
+ )
+ );
+
+ return NextResponse.json(payload, {
+ status: response.status,
+ });
+ } catch (error) {
+ return NextResponse.json(
+ fail(
+ "notification_service_unavailable",
+ "Unable to reach the notification service.",
+ {
+ cause: error instanceof Error ? error.message : String(error),
+ }
+ ),
+ {
+ status: 502,
+ }
+ );
+ }
+}
diff --git a/src/lib/response.ts b/src/lib/response.ts
new file mode 100644
index 0000000..a8f45ff
--- /dev/null
+++ b/src/lib/response.ts
@@ -0,0 +1,42 @@
+import type { ZodError } from "zod";
+
+import type { ApiErrorPayload, ApiResponse } from "@/lib/contracts";
+
+export function ok(data: T, meta?: Record): ApiResponse {
+ if (meta) {
+ return {
+ success: true,
+ data,
+ meta,
+ };
+ }
+
+ return {
+ success: true,
+ data,
+ };
+}
+
+export function fail(
+ code: string,
+ message: string,
+ details?: unknown
+): ApiResponse {
+ const error: ApiErrorPayload = {
+ code,
+ message,
+ };
+
+ if (details !== undefined) {
+ error.details = details;
+ }
+
+ return {
+ success: false,
+ error,
+ };
+}
+
+export function validationDetails(error: ZodError) {
+ return error.flatten();
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..b0dff94
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,35 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "types": ["bun-types"],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}