Add notifications feature and update feature flags

This update includes creating new files for the notifications feature along with adding two feature flags for enabling notifications and realtime notifications. All the code and package dependencies required for the notifications functionality have been added. The 'pnpm-lock.yaml' has also been updated due to the inclusion of new package dependencies.
This commit is contained in:
giancarlo
2024-04-29 18:12:30 +07:00
parent b78e716298
commit 820ed1f56b
22 changed files with 9857 additions and 10538 deletions

View File

@@ -1,6 +1,8 @@
import { PageHeader } from '@kit/ui/page';
import UserLayoutMobileNavigation from './user-layout-mobile-navigation';
import { UserNotifications } from '~/(dashboard)/home/(user)/_components/user-notifications';
import { UserLayoutMobileNavigation } from './user-layout-mobile-navigation';
export function UserAccountHeader(
props: React.PropsWithChildren<{
@@ -14,7 +16,11 @@ export function UserAccountHeader(
description={props.description}
mobileNavigation={<UserLayoutMobileNavigation />}
>
{props.children}
<div className={'flex space-x-4'}>
{props.children}
<UserNotifications />
</div>
</PageHeader>
);
}

View File

@@ -64,8 +64,6 @@ export function UserLayoutMobileNavigation() {
);
}
export default UserLayoutMobileNavigation;
function DropdownLink(
props: React.PropsWithChildren<{
path: string;

View File

@@ -0,0 +1,22 @@
import { use } from 'react';
import { NotificationsPopover } from '@kit/notifications/components';
import featuresFlagConfig from '~/config/feature-flags.config';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
export function UserNotifications() {
const { user } = use(loadUserWorkspace());
if (!featuresFlagConfig.enableNotifications) {
return null;
}
return (
<NotificationsPopover
accountIds={[user.id]}
realtime={featuresFlagConfig.realtimeNotifications}
/>
);
}

View File

@@ -1,5 +1,7 @@
import { PageHeader } from '@kit/ui/page';
import { AccountNotifications } from '~/(dashboard)/home/[account]/_components/account-notifications';
import { AccountLayoutMobileNavigation } from './account-layout-mobile-navigation';
export function AccountLayoutHeader({
@@ -18,7 +20,11 @@ export function AccountLayoutHeader({
description={description}
mobileNavigation={<AccountLayoutMobileNavigation account={account} />}
>
{children}
<div className={'flex space-x-4'}>
{children}
<AccountNotifications accountId={account} />
</div>
</PageHeader>
);
}

View File

@@ -0,0 +1,22 @@
import { use } from 'react';
import { NotificationsPopover } from '@kit/notifications/components';
import featuresFlagConfig from '~/config/feature-flags.config';
import { loadTeamWorkspace } from '../_lib/server/team-account-workspace.loader';
export function AccountNotifications(params: { accountId: string }) {
const { user, account } = use(loadTeamWorkspace(params.accountId));
if (!featuresFlagConfig.enableNotifications) {
return null;
}
return (
<NotificationsPopover
accountIds={[user.id, account.id]}
realtime={featuresFlagConfig.realtimeNotifications}
/>
);
}

View File

@@ -40,6 +40,18 @@ const FeatureFlagsSchema = z.object({
description: `If set to user, use the user's preferred language. If set to application, use the application's default language.`,
})
.default('application'),
enableNotifications: z
.boolean({
description: 'Enable notifications functionality',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS',
})
.default(true),
realtimeNotifications: z
.boolean({
description: 'Enable realtime for the notifications functionality',
required_error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS',
})
.default(true),
});
const featuresFlagConfig = FeatureFlagsSchema.parse({
@@ -74,6 +86,14 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({
languagePriority: process.env.NEXT_PUBLIC_LANGUAGE_PRIORITY as
| 'user'
| 'application',
enableNotifications: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS,
true,
),
realtimeNotifications: getBoolean(
process.env.NEXT_PUBLIC_REALTIME_NOTIFICATIONS,
false,
),
} satisfies z.infer<typeof FeatureFlagsSchema>);
export default featuresFlagConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ const INTERNAL_PACKAGES = [
'@kit/cms',
'@kit/monitoring',
'@kit/next',
'@kit/notifications'
];
/** @type {import('next').NextConfig} */

View File

@@ -45,6 +45,7 @@
"@kit/mailers": "workspace:^",
"@kit/monitoring": "workspace:^",
"@kit/next": "workspace:^",
"@kit/notifications": "workspace:^",
"@kit/shared": "workspace:^",
"@kit/supabase": "workspace:^",
"@kit/team-accounts": "workspace:^",

View File

@@ -48,6 +48,9 @@
"noData": "No data available",
"pageNotFoundHeading": "Ouch! :|",
"errorPageHeading": "Ouch! :|",
"notifications": "Notifications",
"noNotifications": "No notifications",
"justNow": "Just now",
"roles": {
"owner": {
"label": "Owner"

View File

@@ -1969,6 +1969,128 @@ execute on function public.upsert_order (
jsonb
) to service_role;
/**
* -------------------------------------------------------
* Section: Notifications
* We create the schema for the notifications. Notifications are the notifications for an account.
* -------------------------------------------------------
*/
create type public.notification_channel as enum ('in_app', 'email');
create type public.notification_type as enum ('info', 'warning', 'error');
create table if not exists public.notifications (
id bigint generated always as identity primary key,
account_id uuid not null references public.accounts(id) on delete cascade,
type public.notification_type not null default 'info',
body varchar(5000) not null,
link varchar(255),
entity_id text,
entity_type text,
channel public.notification_channel not null default 'in_app',
language_code varchar(10) not null default 'en',
dismissed boolean not null default false,
expires_at timestamptz default (now() + interval '1 month'),
created_at timestamptz not null default now()
);
comment on table notifications is 'The notifications for an account';
comment on column notifications.account_id is 'The account the notification is for (null for system messages)';
comment on column notifications.type is 'The type of the notification';
comment on column notifications.body is 'The body of the notification';
comment on column notifications.link is 'The link for the notification';
comment on column notifications.entity_id is 'The entity ID for the notification';
comment on column notifications.entity_type is 'The entity type for the notification';
comment on column notifications.channel is 'The channel for the notification';
comment on column notifications.language_code is 'The language code for the notification';
comment on column notifications.dismissed is 'Whether the notification has been dismissed';
comment on column notifications.expires_at is 'The expiry date for the notification';
comment on column notifications.created_at is 'The creation date for the notification';
-- Open up access to order_items table for authenticated users and service_role
grant
select, update
on table public.notifications to authenticated,
service_role;
grant insert on table public.notifications to service_role;
-- enable realtime
alter
publication supabase_realtime add table public.notifications;
-- Indexes
-- Indexes on the notifications table
-- index for selecting notifications for an account that are not dismissed and not expired
create index idx_notifications_account_dismissed on notifications(account_id, dismissed, expires_at);
-- RLS
alter table public.notifications enable row level security;
-- SELECT(notifications):
-- Users can read notifications on an account they are a member of
create policy notifications_read_self on public.notifications for
select
to authenticated using (
account_id = (
select
auth.uid ()
)
or has_role_on_account (account_id)
);
-- UPDATE(notifications):
-- Users can set notifications to read on an account they are a member of
create policy notifications_update_self on public.notifications for
update
to authenticated using (
account_id = (
select
auth.uid ()
)
or has_role_on_account (account_id)
);
-- Function "kit.update_notification_dismissed_status"
-- Make sure the only updatable field is the dismissed status and nothing else
create or replace function kit.update_notification_dismissed_status()
returns trigger
set search_path to ''
as $$
begin
old.dismissed := new.dismissed;
if (new is distinct from old) then
raise exception 'UPDATE of columns other than "dismissed" is forbidden';
end if;
return old;
end;
$$ language plpgsql;
-- add trigger when updating a notification to update the dismissed status
create trigger update_notification_dismissed_status before update on public.notifications for each row
execute procedure kit.update_notification_dismissed_status();
/**
* -------------------------------------------------------
* Section: Slugify
* We create the schema for the slugify functions. Slugify functions are used to create slugs from strings.
* We use this for ensure unique slugs for accounts.
* -------------------------------------------------------
*/
-- Create a function to slugify a string
-- useful for turning an account name into a unique slug
create
@@ -2131,6 +2253,13 @@ create trigger on_auth_user_created
after insert on auth.users for each row
execute procedure kit.setup_new_user ();
/**
* -------------------------------------------------------
* Section: Functions
* We create the schema for the functions. Functions are the custom functions for the application.
* -------------------------------------------------------
*/
-- Function "public.create_team_account"
-- Create a team account if team accounts are enabled
create

View File

@@ -0,0 +1,45 @@
{
"name": "@kit/notifications",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"exports": {
"./api": "./src/server/api.ts",
"./components": "./src/components/index.ts",
"./hooks": "./src/hooks/index.ts"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "^2.42.7",
"@types/react": "^18.3.1",
"lucide-react": "^0.376.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-i18next": "^14.1.1"
},
"prettier": "@kit/prettier-config",
"eslintConfig": {
"root": true,
"extends": [
"@kit/eslint-config/base",
"@kit/eslint-config/react"
]
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1 @@
export * from './notifications-popover';

View File

@@ -0,0 +1,237 @@
'use client';
import { useCallback, useState } from 'react';
import { Bell, CircleAlert, Info, TriangleAlert, XIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Database } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
import { Divider } from '@kit/ui/divider';
import { If } from '@kit/ui/if';
import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';
import { cn } from '@kit/ui/utils';
import { useDismissNotification, useFetchNotifications } from '../hooks';
type Notification = Database['public']['Tables']['notifications']['Row'];
type PartialNotification = Pick<
Notification,
'id' | 'body' | 'dismissed' | 'type' | 'created_at' | 'link'
>;
export function NotificationsPopover(params: {
realtime: boolean;
accountIds: string[];
onClick?: (notification: PartialNotification) => void;
}) {
const { i18n, t } = useTranslation();
const [open, setOpen] = useState(false);
const [notifications, setNotifications] = useState<PartialNotification[]>([]);
const onNotifications = useCallback(
(notifications: PartialNotification[]) => {
setNotifications((existing) => [...notifications, ...existing]);
},
[],
);
const dismissNotification = useDismissNotification();
useFetchNotifications({
onNotifications,
accountIds: params.accountIds,
realtime: params.realtime,
});
const unread = notifications.filter(
(notification) => !notification.dismissed,
);
const timeAgo = (createdAt: string) => {
const date = new Date(createdAt);
let time: number;
const daysAgo = Math.floor(
(new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
);
const formatter = new Intl.RelativeTimeFormat(i18n.language, {
numeric: 'auto',
});
if (daysAgo < 1) {
time = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60));
if (time < 5) {
return t('common:justNow');
}
if (time < 60) {
return formatter.format(-time, 'minute');
}
const hours = Math.floor(time / 60);
return formatter.format(-hours, 'hour');
}
const unit = (() => {
const minutesAgo = Math.floor(
(new Date().getTime() - date.getTime()) / (1000 * 60),
);
if (minutesAgo <= 60) {
return 'minute';
}
if (daysAgo <= 1) {
return 'hour';
}
if (daysAgo <= 30) {
return 'day';
}
if (daysAgo <= 365) {
return 'month';
}
return 'year';
})();
const text = formatter.format(-daysAgo, unit);
return text.slice(0, 1).toUpperCase() + text.slice(1);
};
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button className={'h-8 w-8'} variant={'ghost'}>
<Bell className={'min-h-5 min-w-5'} />
<span
className={cn(
`fade-in animate-in zoom-in absolute right-5 top-5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-500 text-[0.65rem] text-white`,
{
hidden: !unread.length,
},
)}
>
{unread.length}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className={'flex flex-col p-0'}
collisionPadding={{ right: 20 }}
>
<div className={'flex items-center px-3 py-2 text-sm font-semibold'}>
{t('common:notifications')}
</div>
<Divider />
<If condition={!notifications.length}>
<div className={'px-3 py-2 text-sm'}>
{t('common:noNotifications')}
</div>
</If>
<div
className={
'flex max-h-[60vh] flex-col divide-y divide-gray-100 overflow-y-auto dark:divide-gray-800'
}
>
{notifications.map((notification) => {
const maxChars = 100;
let body = t(notification.body, {
defaultValue: notification.body,
});
if (body.length > maxChars) {
body = body.substring(0, maxChars) + '...';
}
const Icon = () => {
switch (notification.type) {
case 'warning':
return <TriangleAlert className={'h-4 text-yellow-500'} />;
case 'error':
return <CircleAlert className={'text-destructive h-4'} />;
default:
return <Info className={'h-4 text-blue-500'} />;
}
};
return (
<div
key={notification.id.toString()}
className={cn(
'min-h-18 flex flex-col items-start justify-center space-y-0.5 px-3 py-2',
)}
onClick={() => {
if (params.onClick) {
params.onClick(notification);
}
}}
>
<div className={'flex w-full items-start justify-between'}>
<div
className={'flex items-start justify-start space-x-2 py-2'}
>
<div className={'py-0.5'}>
<Icon />
</div>
<div className={'flex flex-col space-y-1'}>
<div className={'text-sm'}>
<If condition={notification.link} fallback={body}>
{(link) => (
<a href={link} className={'hover:underline'}>
{body}
</a>
)}
</If>
</div>
<span className={'text-muted-foreground text-xs'}>
{timeAgo(notification.created_at)}
</span>
</div>
</div>
<div className={'py-2'}>
<Button
className={'max-h-6 max-w-6'}
size={'icon'}
variant={'ghost'}
onClick={() => {
setNotifications((existing) => {
return existing.filter(
(existingNotification) =>
existingNotification.id !== notification.id,
);
});
return dismissNotification(notification.id);
}}
>
<XIcon className={'h-3'} />
</Button>
</div>
</div>
</div>
);
})}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,2 @@
export * from './use-fetch-notifications';
export * from './use-dismiss-notification';

View File

@@ -0,0 +1,21 @@
import { useCallback } from 'react';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function useDismissNotification() {
const client = useSupabase();
return useCallback(
async (notification: number) => {
const { error } = await client
.from('notifications')
.update({ dismissed: true })
.eq('id', notification);
if (error) {
throw error;
}
},
[client],
);
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useRef } from 'react';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
type Notification = {
id: number;
body: string;
dismissed: boolean;
type: 'info' | 'warning' | 'error';
created_at: string;
link: string | null;
entity_id: string | null;
entity_type: string | null;
};
export function useFetchNotifications({
onNotifications,
accountIds,
realtime,
}: {
onNotifications: (notifications: Notification[]) => unknown;
accountIds: string[];
realtime: boolean;
}) {
const client = useSupabase();
const didFetchInitialData = useRef(false);
useEffect(() => {
let realtimeSubscription: { unsubscribe: () => void } | null = null;
if (realtime) {
const channel = client.channel('notifications-channel');
realtimeSubscription = channel
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
filter: `account_id=in.(${accountIds.join(', ')})`,
table: 'notifications',
},
(payload) => {
onNotifications([payload.new as Notification]);
},
)
.subscribe();
}
if (!didFetchInitialData.current) {
const now = new Date().toISOString();
const initialFetch = client
.from('notifications')
.select(
`id,
body,
dismissed,
type,
created_at,
link,
entity_id,
entity_type
`,
)
.in('account_id', accountIds)
.eq('dismissed', false)
.gt('expires_at', now)
.order('created_at', { ascending: false })
.limit(10);
didFetchInitialData.current = true;
void initialFetch.then(({ data, error }) => {
if (error) {
throw error;
}
if (data) {
onNotifications(data);
}
});
}
return () => {
if (realtimeSubscription) {
realtimeSubscription.unsubscribe();
}
};
}, [client, onNotifications, accountIds, realtime]);
}

View File

@@ -0,0 +1,53 @@
/**
* @file API for notifications
*
* Usage
*
* ```typescript
* import { createNotificationsApi } from '@kit/notifications/api';
*
* const api = createNotificationsApi(client);
*
* await api.createNotification({
* body: 'Hello, world!',
* account_id: '123',
* type: 'info',
* });
* ```
*
*/
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import { createNotificationsService } from './notifications.service';
type Notification = Database['public']['Tables']['notifications'];
/**
* @name createNotificationsApi
* @param client
*/
export function createNotificationsApi(client: SupabaseClient<Database>) {
return new NotificationsApi(client);
}
/**
* @name NotificationsApi
*/
class NotificationsApi {
private readonly service: ReturnType<typeof createNotificationsService>;
constructor(private readonly client: SupabaseClient<Database>) {
this.service = createNotificationsService(client);
}
/**
* @name createNotification
* @description Create a new notification in the database
* @param params
*/
createNotification(params: Notification['Insert']) {
return this.service.createNotification(params);
}
}

View File

@@ -0,0 +1,23 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
type Notification = Database['public']['Tables']['notifications'];
export function createNotificationsService(client: SupabaseClient<Database>) {
return new NotificationsService(client);
}
class NotificationsService {
constructor(private readonly client: SupabaseClient<Database>) {}
async createNotification(params: Notification['Insert']) {
const { error } = await this.client.from('notifications').insert(params);
if (error) {
throw error;
}
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

File diff suppressed because it is too large Load Diff

15494
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff