mirror of https://github.com/nocodb/nocodb
Browse Source
* feat: notifications wip * feat: wip * feat: longpoll and notifications.controller.ts * feat: longpoll and notifications.controller.ts * feat: enable email notifications * fix: notification styles and list * fix: update swagger feat: connect poller to frontend * fix: minor ui corrections * feat: move notifications to ee feat: scroll to commentId fix: polling fail on network error fix: unreadcount not updating fix: add workspace to comment mention event * fix: pubsub for notifications * fix: warning maxListeners * fix: update ui * fix: minor fixes * chore: move pub-sub to redis folder * fix: update ui and schema feat: optimistic comment update and create * fix: row empty during inital load causing row not loading * fix: build * fix: some updated * fix: minor ui corrections * fix: manage local state manually for interactivity * fix: remove prev notifcation data * fix: review comments * fix: code rabbit comments * fix: code rabbit comments * feat: delete notifications * fix: code rabbit comments * fix: row RowMeta manipulation fix: overflow notifications * fix: invalid offset * fix: updated widths * fix: tests * fix: playwright * feat: resolved by comments * feat: update layout * fix: wait 5 seconds before polling start, after polling starts, reload the notifications * fix: bug fixes * fix: disable long polling for playwright * fix: update migration * fix: lint * fix: code rabbit comments * fix: resolve tooltip * feat: resolve ee * fix: build failing * fix: review comments * fix: dependency synx * fix: update notification stylepull/8671/head
Anbarasu
6 months ago
committed by
GitHub
72 changed files with 3007 additions and 1628 deletions
After Width: | Height: | Size: 940 B |
@ -1,17 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import type { ProjectEventType } from 'nocodb-sdk' |
||||
|
||||
const props = defineProps<{ |
||||
item: ProjectEventType |
||||
}>() |
||||
|
||||
const item = toRef(props, 'item') |
||||
</script> |
||||
|
||||
<template> |
||||
<NotificationItemWrapper :item="item"> |
||||
<div class="text-xs"> |
||||
<strong>{{ item.data?.user?.name }}</strong> {{ item.data?.action }} <strong>{{ item.data?.table?.name }}</strong> |
||||
</div> |
||||
</NotificationItemWrapper> |
||||
</template> |
@ -1,17 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import type { ProjectEventType } from 'nocodb-sdk' |
||||
|
||||
const props = defineProps<{ |
||||
item: ProjectEventType |
||||
}>() |
||||
|
||||
const item = toRef(props, 'item') |
||||
</script> |
||||
|
||||
<template> |
||||
<NotificationItemWrapper :item="item"> |
||||
<div class="text-xs"> |
||||
<strong>{{ item.data?.user?.name }}</strong> {{ item.data?.action }} <strong>{{ item.data?.table?.name }}</strong> |
||||
</div> |
||||
</NotificationItemWrapper> |
||||
</template> |
@ -1,38 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import type { ProjectEventType } from 'nocodb-sdk' |
||||
import { AppEvents } from 'nocodb-sdk' |
||||
|
||||
const props = defineProps<{ |
||||
item: ProjectEventType |
||||
}>() |
||||
|
||||
const item = toRef(props, 'item') |
||||
|
||||
const { navigateToProject } = useGlobal() |
||||
|
||||
const action = computed(() => { |
||||
switch (item.value.type) { |
||||
case AppEvents.PROJECT_CREATE: |
||||
return 'created' |
||||
case AppEvents.PROJECT_UPDATE: |
||||
return 'updated' |
||||
case AppEvents.PROJECT_DELETE: |
||||
return 'deleted' |
||||
} |
||||
}) |
||||
|
||||
const onClick = () => { |
||||
if (item.value.type === AppEvents.PROJECT_DELETE) return |
||||
navigateToProject({ baseId: item.value.body.id }) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<NotificationItemWrapper :item="item" @click="onClick"> |
||||
<div class="text-xs gap-2"> |
||||
Base |
||||
<GeneralProjectIcon style="vertical-align: middle" :type="item.body.type" /> <strong>{{ item.body.title }}</strong> |
||||
{{ action }} successfully |
||||
</div> |
||||
</NotificationItemWrapper> |
||||
</template> |
@ -1,38 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import type { ProjectEventType } from 'nocodb-sdk' |
||||
import { AppEvents } from 'nocodb-sdk' |
||||
|
||||
const props = defineProps<{ |
||||
item: ProjectEventType |
||||
}>() |
||||
|
||||
const item = toRef(props, 'item') |
||||
|
||||
const { navigateToProject } = useGlobal() |
||||
|
||||
const action = computed(() => { |
||||
switch (item.value.type) { |
||||
case AppEvents.VIEW_CREATE: |
||||
return 'created' |
||||
case AppEvents.VIEW_UPDATE: |
||||
return 'updated' |
||||
case AppEvents.VIEW_DELETE: |
||||
return 'deleted' |
||||
} |
||||
}) |
||||
|
||||
const onClick = () => { |
||||
if (item.value.type === AppEvents.VIEW_DELETE) return |
||||
navigateToProject({ baseId: item.value.body.id }) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<NotificationItemWrapper :item="item" @click="onClick"> |
||||
<div class="text-xs gap-2"> |
||||
Shared view |
||||
<strong>{{ item.body.title }}</strong> |
||||
{{ action }} successfully |
||||
</div> |
||||
</NotificationItemWrapper> |
||||
</template> |
@ -1,17 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import type { ProjectEventType } from 'nocodb-sdk' |
||||
|
||||
const props = defineProps<{ |
||||
item: ProjectEventType |
||||
}>() |
||||
|
||||
const item = toRef(props, 'item') |
||||
</script> |
||||
|
||||
<template> |
||||
<NotificationItemWrapper :item="item"> |
||||
<div class="text-xs"> |
||||
<strong>{{ item.data?.user?.name }}</strong> {{ item.data?.action }} <strong>{{ item.data?.table?.name }}</strong> |
||||
</div> |
||||
</NotificationItemWrapper> |
||||
</template> |
@ -1,38 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import type { TableEventType } from 'nocodb-sdk' |
||||
import { AppEvents } from 'nocodb-sdk' |
||||
|
||||
const props = defineProps<{ |
||||
item: TableEventType |
||||
}>() |
||||
|
||||
const item = toRef(props, 'item') |
||||
|
||||
const { navigateToProject } = useGlobal() |
||||
|
||||
const action = computed(() => { |
||||
switch (item.value.type) { |
||||
case AppEvents.TABLE_CREATE: |
||||
return 'created' |
||||
case AppEvents.TABLE_UPDATE: |
||||
return 'updated' |
||||
case AppEvents.TABLE_DELETE: |
||||
return 'deleted' |
||||
} |
||||
}) |
||||
|
||||
const onClick = () => { |
||||
if (item.value.type === AppEvents.TABLE_DELETE) return |
||||
navigateToProject({ baseId: item.value.body.id }) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<NotificationItemWrapper :item="item" @click="onClick"> |
||||
<div class="text-xs gap-2"> |
||||
Table |
||||
<strong>{{ item.body.title }}</strong> |
||||
{{ action }} successfully |
||||
</div> |
||||
</NotificationItemWrapper> |
||||
</template> |
@ -1,38 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import type { ProjectEventType } from 'nocodb-sdk' |
||||
import { AppEvents } from 'nocodb-sdk' |
||||
|
||||
const props = defineProps<{ |
||||
item: ProjectEventType |
||||
}>() |
||||
|
||||
const item = toRef(props, 'item') |
||||
|
||||
const { navigateToProject } = useGlobal() |
||||
|
||||
const action = computed(() => { |
||||
switch (item.value.type) { |
||||
case AppEvents.VIEW_CREATE: |
||||
return 'created' |
||||
case AppEvents.VIEW_UPDATE: |
||||
return 'updated' |
||||
case AppEvents.VIEW_DELETE: |
||||
return 'deleted' |
||||
} |
||||
}) |
||||
|
||||
const onClick = () => { |
||||
if (item.value.type === AppEvents.VIEW_DELETE) return |
||||
navigateToProject({ baseId: item.value.body.id }) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<NotificationItemWrapper :item="item" @click="onClick"> |
||||
<div class="text-xs gap-2"> |
||||
View |
||||
<strong>{{ item.body.title }}</strong> |
||||
{{ action }} successfully |
||||
</div> |
||||
</NotificationItemWrapper> |
||||
</template> |
@ -1,26 +1,15 @@
|
||||
<script setup lang="ts"> |
||||
import type { WelcomeEventType } from 'nocodb-sdk' |
||||
|
||||
const props = defineProps<{ |
||||
item: any |
||||
item: WelcomeEventType |
||||
}>() |
||||
|
||||
const router = useRouter() |
||||
const route = router.currentRoute |
||||
|
||||
const item = toRef(props, 'item') |
||||
|
||||
const navigateToHome = () => { |
||||
if (route.value.path !== '/') { |
||||
navigateTo(`/`) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<NotificationItemWrapper :item="item" @click="navigateToHome"> |
||||
<template #avatar> |
||||
<img src="~/assets/img/icons/64x64.png" class="w-6" /> |
||||
</template> |
||||
|
||||
<div class="text-xs">Welcome to <strong>NocoHUB!</strong> We’re excited to have you onboard.</div> |
||||
<NotificationItemWrapper :item="item"> |
||||
<div>Welcome to <span class="font-semibold">NocoDB!</span> We’re excited to have you onboard.</div> |
||||
</NotificationItemWrapper> |
||||
</template> |
||||
|
@ -1,94 +1,73 @@
|
||||
<script setup lang="ts"> |
||||
import { timeAgo } from 'nocodb-sdk' |
||||
import type { NotificationType } from 'nocodb-sdk' |
||||
import { timeAgo } from '~/utils/datetimeUtils' |
||||
|
||||
const props = defineProps<{ |
||||
item: { |
||||
created_at: any |
||||
} |
||||
item: NotificationType |
||||
}>() |
||||
|
||||
const item = toRef(props, 'item') |
||||
|
||||
const { isMobileMode } = useGlobal() |
||||
|
||||
const notificationStore = useNotification() |
||||
|
||||
const { markAsRead } = notificationStore |
||||
const { toggleRead, deleteNotification } = notificationStore |
||||
</script> |
||||
|
||||
<template> |
||||
<div |
||||
class="flex items-center gap-1 cursor-pointer nc-notification-item-wrapper" |
||||
:class="{ |
||||
active: !item.is_read, |
||||
}" |
||||
> |
||||
<div class="nc-notification-dot" :class="{ active: !item.is_read }"></div> |
||||
<div class="nc-avatar-wrapper"> |
||||
<div class="flex pl-6 pr-4 w-full overflow-x-hidden group py-4 hover:bg-gray-50 gap-3 relative cursor-pointer"> |
||||
<div class="w-9.625"> |
||||
<slot name="avatar"> |
||||
<div class="nc-notification-avatar"></div> |
||||
<img src="~assets/img/brand/nocodb-logo.svg" alt="NocoDB" class="w-8" /> |
||||
</slot> |
||||
</div> |
||||
<div class="flex-grow ml-3"> |
||||
<div class="flex items-center"> |
||||
<slot /> |
||||
</div> |
||||
<div |
||||
v-if="item" |
||||
class="text-xs text-gray-500 mt-1" |
||||
|
||||
<div class="text-[13px] min-h-12 w-full leading-5"> |
||||
<slot /> |
||||
</div> |
||||
<div v-if="item" class="text-xs whitespace-nowrap absolute right-4.1 bottom-5 text-gray-600"> |
||||
{{ timeAgo(item.created_at) }} |
||||
</div> |
||||
<div class="flex items-start"> |
||||
<NcTooltip v-if="!item.is_read"> |
||||
<template #title> |
||||
<span>Mark as read</span> |
||||
</template> |
||||
|
||||
<NcButton |
||||
:class="{ |
||||
'!opacity-100': isMobileMode, |
||||
}" |
||||
type="secondary" |
||||
class="!border-0 transition-all duration-100 opacity-0 !group-hover:opacity-100" |
||||
size="xsmall" |
||||
@click.stop="() => toggleRead(item)" |
||||
> |
||||
<GeneralIcon icon="check" class="text-gray-700" /> |
||||
</NcButton> |
||||
</NcTooltip> |
||||
<NcDropdown |
||||
v-else |
||||
:class="{ |
||||
'text-primary': !item.is_read, |
||||
'!opacity-100': isMobileMode, |
||||
}" |
||||
class="transition-all duration-100 opacity-0 !group-hover:opacity-100" |
||||
> |
||||
{{ timeAgo(item.created_at) }} |
||||
</div> |
||||
</div> |
||||
<div @click.stop> |
||||
<a-dropdown> |
||||
<GeneralIcon v-if="!item.is_read" icon="threeDotVertical" class="nc-notification-menu-icon" /> |
||||
<NcButton size="xsmall" type="secondary" @click.stop> |
||||
<GeneralIcon icon="threeDotVertical" /> |
||||
</NcButton> |
||||
|
||||
<template #overlay> |
||||
<a-menu> |
||||
<a-menu-item @click="markAsRead(item)"> |
||||
<div class="p-2 text-xs">Mark as read</div> |
||||
</a-menu-item> |
||||
</a-menu> |
||||
<NcMenu> |
||||
<NcMenuItem @click.stop="() => toggleRead(item)"> Mark as unread </NcMenuItem> |
||||
<NcDivider /> |
||||
<NcMenuItem class="!text-red-500 !hover:bg-red-50" @click.stop="deleteNotification(item)"> Delete </NcMenuItem> |
||||
</NcMenu> |
||||
</template> |
||||
</a-dropdown> |
||||
</NcDropdown> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"> |
||||
.nc-avatar-wrapper { |
||||
@apply min-w-6 h-6 flex items-center justify-center; |
||||
} |
||||
|
||||
.nc-notification-avatar { |
||||
@apply w-6 h-6 rounded-full text-white font-weight-bold uppercase bg-gray-100; |
||||
font-size: 0.7rem; |
||||
} |
||||
|
||||
.nc-notification-dot { |
||||
@apply min-w-2 min-h-2 mr-1 rounded-full; |
||||
|
||||
&.active { |
||||
@apply bg-accent bg-opacity-100; |
||||
} |
||||
} |
||||
|
||||
.nc-notification-item-wrapper { |
||||
.nc-notification-menu-icon { |
||||
@apply !text-12px text-gray-500 opacity-0 transition-opacity duration-200 cursor-pointer; |
||||
} |
||||
|
||||
&:hover { |
||||
.nc-notification-menu-icon { |
||||
@apply opacity-100; |
||||
} |
||||
} |
||||
|
||||
&.active { |
||||
@apply bg-primary bg-opacity-4; |
||||
} |
||||
|
||||
@apply py-3 px-3; |
||||
} |
||||
</style> |
||||
<style scoped lang="scss"></style> |
||||
|
@ -0,0 +1,23 @@
|
||||
{ |
||||
"name": "nc-mail-templates", |
||||
"version": "1.0.0", |
||||
"description": "", |
||||
"main": "index.js", |
||||
"type": "module", |
||||
"scripts": { |
||||
"generate": "pnpm build && scripts/move_templates.sh", |
||||
"build": "tsx ./src/index.ts" |
||||
}, |
||||
"keywords": [], |
||||
"author": "", |
||||
"license": "ISC", |
||||
"dependencies": { |
||||
"html-entities-decoder": "^1.0.5", |
||||
"typescript": "^5.3.3", |
||||
"vue-extensible-mail": "^0.0.3 " |
||||
}, |
||||
"devDependencies": { |
||||
"tsx": "^4.10.4", |
||||
"@types/html-entities-decoder": "^1.0.2" |
||||
} |
||||
} |
@ -0,0 +1,33 @@
|
||||
import {createEmailClient} from "vue-extensible-mail"; |
||||
import components from "./templates/components"; |
||||
import assets from "./templates/assets"; |
||||
import * as fs from "node:fs"; |
||||
import decode from 'html-entities-decoder'; |
||||
|
||||
|
||||
const emailClient = createEmailClient({ |
||||
path: "./src/templates", |
||||
components: { |
||||
...components, |
||||
...assets |
||||
}, |
||||
}); |
||||
|
||||
|
||||
const exportString = 'export default `' |
||||
|
||||
if (fs.existsSync("./.dist")) { |
||||
fs.rmdirSync("./.dist", {recursive: true}); |
||||
} |
||||
|
||||
|
||||
for (const file of fs.readdirSync("./src/templates")) { |
||||
if (file.endsWith(".vue")) { |
||||
const markup = decode(await emailClient.renderEmail(file, {}, {})) |
||||
|
||||
if (!fs.existsSync("./.dist")) { |
||||
fs.mkdirSync("./.dist"); |
||||
} |
||||
fs.writeFileSync(`./.dist/${file.replace(".vue", ".ts")}`, exportString + markup + "`") |
||||
} |
||||
} |
@ -0,0 +1,73 @@
|
||||
<script lang="ts" setup> |
||||
import {defaultComponents} from "vue-extensible-mail"; |
||||
|
||||
const { Container } = defaultComponents; |
||||
</script> |
||||
|
||||
<template> |
||||
<HtmlWrapper> |
||||
<Header /> |
||||
|
||||
<Container> |
||||
<div style="max-width: 400px"> |
||||
<h1 |
||||
style=" |
||||
font-size: 40px; |
||||
font-weight: bold; |
||||
color: #101015; |
||||
margin-block: 64px; |
||||
" |
||||
> |
||||
You have been mentioned. |
||||
</h1> |
||||
<p |
||||
style=" |
||||
font-size: 14px; |
||||
color: #1f293a; |
||||
margin-block: 64px; |
||||
padding-bottom: 24px; |
||||
padding-top: 24px; |
||||
" |
||||
> |
||||
<strong> <%- name %> </strong> |
||||
has mentioned you in a comment on the |
||||
<strong> <%- display_name %> </strong> |
||||
in the |
||||
<strong> <%- table %> / <%- base %> </strong>. |
||||
</p> |
||||
|
||||
<a href="<%- url %>" style="text-decoration: none"> |
||||
<div |
||||
style=" |
||||
cursor: pointer; |
||||
border-radius: 8px; |
||||
background-color: #3366ff; |
||||
border: none; |
||||
color: white; |
||||
padding-bottom: 15px; |
||||
padding-top: 15px; |
||||
max-width: 426px; |
||||
width: 100%; |
||||
margin-block: 64px; |
||||
text-align: center; |
||||
" |
||||
> |
||||
<span> View Comment </span> |
||||
</div> |
||||
</a> |
||||
|
||||
<p |
||||
style=" |
||||
color: #374151; |
||||
font-size: 14px; |
||||
padding-top: 24px; |
||||
padding-bottom: 24px; |
||||
" |
||||
> |
||||
NocoDB is solution for all your database needs. |
||||
</p> |
||||
</div> |
||||
<Footer style="width: 100%" /> |
||||
</Container> |
||||
</HtmlWrapper> |
||||
</template> |
@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup> |
||||
import {defaultComponents} from "vue-extensible-mail"; |
||||
|
||||
const { Container } = defaultComponents; |
||||
</script> |
||||
|
||||
<template> |
||||
<HtmlWrapper> |
||||
<Header /> |
||||
|
||||
<Container> |
||||
<div> |
||||
<h1 |
||||
style=" |
||||
font-size: 40px; |
||||
font-weight: bold; |
||||
color: #101015; |
||||
margin-block: 64px; |
||||
" |
||||
> |
||||
Welcome to NocoDB! |
||||
</h1> |
||||
<p |
||||
style=" |
||||
font-size: 16px; |
||||
color: #1f293a; |
||||
margin-block: 64px; |
||||
padding-bottom: 24px; |
||||
padding-top: 24px; |
||||
" |
||||
> |
||||
We’re excited to have you onboard. |
||||
</p> |
||||
<a href="https://www.nocodb.com" style="text-decoration: none"> |
||||
<div |
||||
style=" |
||||
cursor: pointer; |
||||
border-radius: 8px; |
||||
background-color: #3366ff; |
||||
border: none; |
||||
color: white; |
||||
padding-bottom: 15px; |
||||
padding-top: 15px; |
||||
max-width: 426px; |
||||
width: 100%; |
||||
margin-block: 64px; |
||||
text-align: center; |
||||
" |
||||
> |
||||
<span> Explore NocoDB </span> |
||||
</div> |
||||
</a> |
||||
|
||||
<p |
||||
style=" |
||||
color: #374151; |
||||
font-size: 14px; |
||||
padding-top: 24px; |
||||
padding-bottom: 24px; |
||||
" |
||||
> |
||||
NocoDB is solution for all your database needs. |
||||
</p> |
||||
</div> |
||||
<Footer style="width: 100%" /> |
||||
</Container> |
||||
</HtmlWrapper> |
||||
</template> |
@ -0,0 +1,15 @@
|
||||
import {defineComponent, h} from "vue"; |
||||
|
||||
export default defineComponent({ |
||||
name: "Discord Icon", |
||||
setup() { |
||||
return () => { |
||||
return h("img", { |
||||
src: "https://app.nocodb.com/discord.png", |
||||
width: "24", |
||||
height: "24", |
||||
alt: "Discord Icon", |
||||
}); |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,15 @@
|
||||
import {defineComponent, h} from "vue"; |
||||
|
||||
export default defineComponent({ |
||||
name: "GithubIcon", |
||||
setup() { |
||||
return () => { |
||||
return h("img", { |
||||
src: "https://app.nocodb.com/github.png", |
||||
alt: "Github Icon", |
||||
width: "24", |
||||
height: "24", |
||||
}); |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,18 @@
|
||||
import {defineComponent, h} from "vue"; |
||||
|
||||
export default defineComponent({ |
||||
name: "LinkedinIcon", |
||||
setup() { |
||||
return () => { |
||||
return h( |
||||
"img", |
||||
{ |
||||
src: "https://app.nocodb.com/linkedin.png", |
||||
alt: "LinkedIn Icon", |
||||
width: "24", |
||||
height: "24", |
||||
}, |
||||
); |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,15 @@
|
||||
import {defineComponent, h} from "vue"; |
||||
|
||||
export default defineComponent({ |
||||
name: "TwitterIcon", |
||||
setup() { |
||||
return () => { |
||||
return h("img", { |
||||
width: "24", |
||||
height: "24", |
||||
src: "https://app.nocodb.com/twitter.png", |
||||
alt: "Twitter Icon", |
||||
}); |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,15 @@
|
||||
import {defineComponent, h} from "vue"; |
||||
|
||||
export default defineComponent({ |
||||
name: "YoutubeIcon", |
||||
setup() { |
||||
return () => { |
||||
return h("img", { |
||||
src: "https://app.nocodb.com/youtube.png", |
||||
alt: "Youtube Icon", |
||||
width: "24", |
||||
height: "24", |
||||
}); |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,15 @@
|
||||
import DiscordIcon from "./DiscordIcon.ts"; |
||||
import GithubIcon from "./GithubIcon.ts"; |
||||
import LinkedIcon from "./LinkedIcon.ts"; |
||||
import TwitterIcon from "./TwitterIcon.ts"; |
||||
import YoutubeIcon from "./YoutubeIcon.ts"; |
||||
|
||||
|
||||
export default { |
||||
GithubIcon, |
||||
TwitterIcon, |
||||
LinkedIcon, |
||||
DiscordIcon, |
||||
YoutubeIcon |
||||
|
||||
} |
@ -0,0 +1,212 @@
|
||||
import {defineComponent, h} from "vue"; |
||||
import Icons from "../assets"; |
||||
|
||||
export default defineComponent({ |
||||
name: "Header", |
||||
setup() { |
||||
return () => { |
||||
return h( |
||||
"div", |
||||
{ |
||||
style: { |
||||
width: "100%", |
||||
"max-width": "426px", |
||||
'padding-block': "24px", |
||||
'margin-block': "64px", |
||||
'text-justify': "distribute", |
||||
}, |
||||
}, |
||||
[ |
||||
h( |
||||
"div", |
||||
{ |
||||
style: { |
||||
width: "100%", |
||||
"max-width": "426px", |
||||
'padding-block': "24px", |
||||
display: "flex", |
||||
'justify-content': "space-between", |
||||
'align-items': "center", |
||||
}, |
||||
}, |
||||
[ |
||||
h("img", { |
||||
src: "https://i.ibb.co/4tbw6Wf/logo.png", |
||||
alt: "NocoDB", |
||||
style: { |
||||
height: "40px", |
||||
}, |
||||
}), |
||||
h( |
||||
'div', |
||||
{ |
||||
style: { |
||||
display: "flex", |
||||
gap: "16px", |
||||
}, |
||||
}, |
||||
[ |
||||
h( |
||||
"a", |
||||
{ |
||||
href: "https://github.com/nocodb", |
||||
style: { |
||||
'text-decoration': "none", |
||||
color: "black", |
||||
'margin-left': "4px", |
||||
}, |
||||
}, |
||||
h(Icons.GithubIcon) |
||||
), |
||||
h( |
||||
"a", |
||||
{ |
||||
href: "https://x.com/nocodb", |
||||
style: { |
||||
'text-decoration': "none", |
||||
color: "black", |
||||
'margin-left': "4px", |
||||
}, |
||||
}, |
||||
h(Icons.TwitterIcon) |
||||
), |
||||
h( |
||||
"a", |
||||
{ |
||||
href: "https://www.youtube.com/@nocodb/videos", |
||||
style: { |
||||
'text-decoration': "none", |
||||
color: "black", |
||||
'margin-left': "4px", |
||||
}, |
||||
}, |
||||
h(Icons.YoutubeIcon) |
||||
), |
||||
h( |
||||
"a", |
||||
{ |
||||
href: "https://discord.gg/c7GEYrvFtT", |
||||
style: { |
||||
'text-decoration': "none", |
||||
color: "black", |
||||
'margin-left': "4px", |
||||
}, |
||||
}, |
||||
h(Icons.DiscordIcon) |
||||
), |
||||
h( |
||||
"a", |
||||
{ |
||||
href: "https://www.linkedin.com/company/nocodb/", |
||||
style: { |
||||
'text-decoration': "none", |
||||
color: "black", |
||||
'margin-left': "4px", |
||||
}, |
||||
}, |
||||
h(Icons.LinkedIcon) |
||||
), |
||||
] |
||||
), |
||||
] |
||||
), |
||||
|
||||
h( |
||||
"div", |
||||
{ |
||||
style: { |
||||
width: "100%", |
||||
|
||||
'padding-top': "24px", |
||||
}, |
||||
}, |
||||
[ |
||||
h( |
||||
"a", |
||||
{ |
||||
href: "https://blog.nocodb.com/", |
||||
style: { |
||||
'text-decoration': "none", |
||||
color: "#6A7184", |
||||
'font-size': "14px", |
||||
}, |
||||
}, |
||||
"Blog" |
||||
), |
||||
" | ", |
||||
h( |
||||
"a", |
||||
{ |
||||
href: "https://docs.nocodb.com/", |
||||
style: { |
||||
'text-decoration': "none", |
||||
color: "#6A7184", |
||||
'font-size': "14px", |
||||
}, |
||||
}, |
||||
"Getting Started" |
||||
), |
||||
" | ", |
||||
h( |
||||
"a", |
||||
{ |
||||
href: "https://docs.nocodb.com/", |
||||
style: { |
||||
'text-decoration': "none", |
||||
color: "#6A7184", |
||||
'font-size': "14px", |
||||
}, |
||||
}, |
||||
"Documentation" |
||||
), |
||||
" | ", |
||||
h( |
||||
"a", |
||||
{ |
||||
href: "https://nocodb.com/terms-of-service", |
||||
style: { |
||||
'text-decoration': "none", |
||||
color: "#6A7184", |
||||
'font-size': "14px", |
||||
}, |
||||
}, |
||||
"Terms of Service" |
||||
), |
||||
] |
||||
), |
||||
|
||||
h( |
||||
"div", |
||||
{ |
||||
style: { |
||||
'padding-top': "24px", |
||||
|
||||
width: "100%", |
||||
'justify-content': "flex-start", |
||||
'align-items': "items-start", |
||||
color: "#6A7184", |
||||
'text-align': "left", |
||||
}, |
||||
}, |
||||
"2024 - NocoDB Inc " |
||||
), |
||||
h( |
||||
"div", |
||||
{ |
||||
style: { |
||||
'padding-top': "24px", |
||||
|
||||
width: "100%", |
||||
'justify-content': "flex-start", |
||||
'align-items': "items-start", |
||||
color: "#6A7184", |
||||
'text-align': "left", |
||||
}, |
||||
}, |
||||
"All rights reserved " |
||||
), |
||||
] |
||||
); |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,30 @@
|
||||
import {defineComponent, h} from "vue"; |
||||
|
||||
export default defineComponent({ |
||||
name: "Header", |
||||
setup() { |
||||
return () => { |
||||
return h( |
||||
"div", |
||||
{ |
||||
style: { |
||||
width: "100%", |
||||
'background-color': "#f4f4f5", |
||||
'padding-top': "48px", |
||||
'padding-bottom': "48px", |
||||
'text-align': "center", |
||||
}, |
||||
}, |
||||
[ |
||||
h("img", { |
||||
src: "https://i.ibb.co/4tbw6Wf/logo.png", |
||||
alt: "NocoDB", |
||||
style: { |
||||
height: "48px", |
||||
}, |
||||
}), |
||||
] |
||||
); |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,56 @@
|
||||
import {defineComponent, h} from "vue"; |
||||
|
||||
export default defineComponent({ |
||||
name: "HtmlWrapper", |
||||
setup(_, { slots }) { |
||||
return () => { |
||||
return h( |
||||
"html", |
||||
{ |
||||
dir: "ltr", |
||||
lang: "en", |
||||
}, |
||||
[ |
||||
h("head", {}, [ |
||||
h("meta", { charset: "utf-8" }), |
||||
h("meta", { name: "x-apple-disable-message-reformatting" }), |
||||
h("meta", { |
||||
content: "text/html; charset=UTF-8", |
||||
"http-equiv": "Content-Type", |
||||
}), |
||||
h("meta", { name: "viewport", content: "width=device-width" }), |
||||
h("link", { |
||||
href: "https://fonts.googleapis.com/css?family=Manrope", |
||||
rel: "stylesheet", |
||||
type: "text/css", |
||||
}), |
||||
h( |
||||
"style", |
||||
{}, |
||||
` |
||||
@font-face { |
||||
src: url(https://fonts.gstatic.com/s/manrope/v15/xn7gYHE41ni1AdIRggqxSvfedN62Zw.woff2) format('woff2');
|
||||
font-family: Maprope, sans-serif; |
||||
} |
||||
|
||||
* { |
||||
font-family: Manrope, sans-serif; |
||||
} |
||||
|
||||
body { |
||||
margin: 0; |
||||
padding: 0; |
||||
box-sizing: border-box; |
||||
font-family: Manrope, sans-serif; |
||||
} |
||||
|
||||
|
||||
` |
||||
), |
||||
]), |
||||
h("body", {}, slots?.default()), |
||||
] |
||||
); |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,9 @@
|
||||
import Footer from "./Footer.ts" |
||||
import Header from "./Header.ts" |
||||
import HtmlWrapper from "./HtmlWrapper.ts" |
||||
|
||||
export default { |
||||
Footer, |
||||
Header, |
||||
HtmlWrapper |
||||
} |
@ -0,0 +1,29 @@
|
||||
{ |
||||
"compilerOptions": { |
||||
// Enable latest features |
||||
"lib": ["ESNext", "DOM"], |
||||
"target": "ESNext", |
||||
"module": "ESNext", |
||||
"moduleDetection": "force", |
||||
"jsx": "preserve", |
||||
"jsxImportSource": "vue", |
||||
"allowJs": true, |
||||
|
||||
// Bundler mode |
||||
"moduleResolution": "bundler", |
||||
"allowImportingTsExtensions": true, |
||||
"verbatimModuleSyntax": true, |
||||
"noEmit": true, |
||||
"esModuleInterop": true, |
||||
|
||||
// Best practices |
||||
"strict": true, |
||||
"skipLibCheck": true, |
||||
"noFallthroughCasesInSwitch": true, |
||||
|
||||
// Some stricter flags (disabled by default) |
||||
"noUnusedLocals": false, |
||||
"noUnusedParameters": false, |
||||
"noPropertyAccessFromIndexSignature": false |
||||
} |
||||
} |
@ -1,19 +0,0 @@
|
||||
import { Test } from '@nestjs/testing'; |
||||
import { NotificationsGateway } from './notifications.gateway'; |
||||
import type { TestingModule } from '@nestjs/testing'; |
||||
|
||||
describe('NotificationsGateway', () => { |
||||
let gateway: NotificationsGateway; |
||||
|
||||
beforeEach(async () => { |
||||
const module: TestingModule = await Test.createTestingModule({ |
||||
providers: [NotificationsGateway], |
||||
}).compile(); |
||||
|
||||
gateway = module.get<NotificationsGateway>(NotificationsGateway); |
||||
}); |
||||
|
||||
it('should be defined', () => { |
||||
expect(gateway).toBeDefined(); |
||||
}); |
||||
}); |
@ -1,59 +0,0 @@
|
||||
import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; |
||||
import { Server } from 'socket.io'; |
||||
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; |
||||
import { AuthGuard } from '@nestjs/passport'; |
||||
import type { Socket } from 'socket.io'; |
||||
import type { OnModuleInit } from '@nestjs/common'; |
||||
import type { NotificationType } from 'nocodb-sdk'; |
||||
import { AppHooksService } from '~/services/app-hooks/app-hooks.service'; |
||||
|
||||
@WebSocketGateway({ |
||||
cors: { |
||||
origin: '*', |
||||
allowedHeaders: ['xc-auth'], |
||||
credentials: true, |
||||
}, |
||||
namespace: 'notifications', |
||||
}) |
||||
export class NotificationsGateway implements OnModuleInit { |
||||
@WebSocketServer() |
||||
server: Server; |
||||
|
||||
private clients: { [id: string]: Socket } = {}; |
||||
|
||||
constructor(private readonly appHooks: AppHooksService) {} |
||||
|
||||
async onModuleInit() { |
||||
this.server |
||||
.use(async (socket, next) => { |
||||
try { |
||||
const context = new ExecutionContextHost([socket.handshake as any]); |
||||
const guard = new (AuthGuard('jwt'))(context); |
||||
await guard.canActivate(context); |
||||
} catch {} |
||||
|
||||
next(); |
||||
}) |
||||
.on('connection', (socket) => { |
||||
if ((socket?.handshake as any)?.user?.id) |
||||
this.clients[(socket?.handshake as any)?.user?.id] = socket; |
||||
}); |
||||
|
||||
// todo: fix
|
||||
this.appHooks.on( |
||||
'notification' as any, |
||||
this.notificationHandler.bind(this), |
||||
); |
||||
} |
||||
|
||||
// todo: verify if this is the right way to do it, since we are binding this context to the handler
|
||||
onModuleDestroy() { |
||||
this.appHooks.removeListener('notification', this.notificationHandler); |
||||
} |
||||
|
||||
private notificationHandler(notification: NotificationType) { |
||||
if (notification?.fk_user_id && this.clients[notification.fk_user_id]) { |
||||
this.clients[notification.fk_user_id]?.emit('notification', notification); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,14 @@
|
||||
import type { Knex } from 'knex'; |
||||
import { MetaTable } from '~/utils/globals'; |
||||
|
||||
const up = async (knex: Knex) => { |
||||
await knex(MetaTable.NOTIFICATION).update({ |
||||
is_deleted: true, |
||||
}); |
||||
}; |
||||
|
||||
const down = async (_knex: Knex) => { |
||||
// no-op
|
||||
}; |
||||
|
||||
export { up, down }; |
@ -0,0 +1,94 @@
|
||||
import { Logger } from '@nestjs/common'; |
||||
import Redis from 'ioredis'; |
||||
|
||||
export class PubSubRedis { |
||||
static initialized = false; |
||||
|
||||
static available = process.env.NC_REDIS_JOB_URL ? true : false; |
||||
|
||||
protected static logger = new Logger(PubSubRedis.name); |
||||
|
||||
static redisClient: Redis; |
||||
private static redisSubscriber: Redis; |
||||
private static unsubscribeCallbacks: { [key: string]: () => Promise<void> } = |
||||
{}; |
||||
private static callbacks: Record<string, (...args) => Promise<void>> = {}; |
||||
|
||||
public static async init() { |
||||
if (!PubSubRedis.available) { |
||||
return; |
||||
} |
||||
|
||||
PubSubRedis.redisClient = new Redis(process.env.NC_REDIS_JOB_URL); |
||||
PubSubRedis.redisSubscriber = new Redis(process.env.NC_REDIS_JOB_URL); |
||||
|
||||
PubSubRedis.redisSubscriber.on('message', async (channel, message) => { |
||||
const [command, ...args] = message.split(':'); |
||||
const callback = PubSubRedis.callbacks[command]; |
||||
if (callback) await callback(...args); |
||||
}); |
||||
|
||||
PubSubRedis.initialized = true; |
||||
} |
||||
|
||||
static async publish(channel: string, message: string | Record<string, any>) { |
||||
if (!PubSubRedis.initialized) { |
||||
if (!PubSubRedis.available) { |
||||
return; |
||||
} |
||||
|
||||
await PubSubRedis.init(); |
||||
} |
||||
try { |
||||
if (typeof message === 'string') { |
||||
await PubSubRedis.redisClient.publish(channel, message); |
||||
} else { |
||||
await PubSubRedis.redisClient.publish(channel, JSON.stringify(message)); |
||||
} |
||||
} catch (e) { |
||||
PubSubRedis.logger.error(e); |
||||
} |
||||
} |
||||
|
||||
static async unsubscribe(channel: string) { |
||||
if (!PubSubRedis.initialized) { |
||||
if (!PubSubRedis.available) { |
||||
return; |
||||
} |
||||
|
||||
await PubSubRedis.init(); |
||||
} |
||||
|
||||
if (PubSubRedis.unsubscribeCallbacks[channel]) { |
||||
await PubSubRedis.unsubscribeCallbacks[channel](); |
||||
delete PubSubRedis.unsubscribeCallbacks[channel]; |
||||
} |
||||
} |
||||
|
||||
static async subscribe( |
||||
channel: string, |
||||
callback: (message: any) => Promise<void>, |
||||
) { |
||||
if (!PubSubRedis.initialized) { |
||||
if (!PubSubRedis.available) { |
||||
return; |
||||
} |
||||
await PubSubRedis.init(); |
||||
} |
||||
|
||||
await PubSubRedis.redisSubscriber.subscribe(channel); |
||||
|
||||
const onMessage = async (_channel, message) => { |
||||
try { |
||||
message = JSON.parse(message); |
||||
} catch (e) {} |
||||
await callback(message); |
||||
}; |
||||
|
||||
PubSubRedis.redisSubscriber.on('message', onMessage); |
||||
PubSubRedis.unsubscribeCallbacks[channel] = async () => { |
||||
await PubSubRedis.redisSubscriber.unsubscribe(channel); |
||||
PubSubRedis.redisSubscriber.off('message', onMessage); |
||||
}; |
||||
} |
||||
} |
@ -1,141 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common'; |
||||
import { AppEvents } from 'nocodb-sdk'; |
||||
import type { |
||||
ProjectInviteEvent, |
||||
WelcomeEvent, |
||||
} from '~/services/app-hooks/interfaces'; |
||||
import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common'; |
||||
import type { UserType } from 'nocodb-sdk'; |
||||
import type { NcRequest } from '~/interface/config'; |
||||
import { AppHooksService } from '~/services/app-hooks/app-hooks.service'; |
||||
import { NcError } from '~/helpers/catchError'; |
||||
import { PagedResponseImpl } from '~/helpers/PagedResponse'; |
||||
import { Notification } from '~/models'; |
||||
|
||||
@Injectable() |
||||
export class NotificationsService implements OnModuleInit, OnModuleDestroy { |
||||
constructor(protected readonly appHooks: AppHooksService) {} |
||||
|
||||
protected async insertNotification( |
||||
insertData: Partial<Notification>, |
||||
req: NcRequest, |
||||
) { |
||||
this.appHooks.emit('notification' as any, { ...insertData, req } as any); |
||||
|
||||
await Notification.insert(insertData); |
||||
} |
||||
|
||||
protected async hookHandler({ |
||||
event, |
||||
data, |
||||
}: { |
||||
event: AppEvents; |
||||
data: any; |
||||
}) { |
||||
const { req } = data; |
||||
switch (event) { |
||||
case AppEvents.PROJECT_INVITE: |
||||
{ |
||||
const { base, user, invitedBy } = data as ProjectInviteEvent; |
||||
|
||||
await this.insertNotification( |
||||
{ |
||||
fk_user_id: user.id, |
||||
type: AppEvents.PROJECT_INVITE, |
||||
body: { |
||||
id: base.id, |
||||
title: base.title, |
||||
type: base.type, |
||||
invited_by: invitedBy.email, |
||||
}, |
||||
}, |
||||
req, |
||||
); |
||||
} |
||||
break; |
||||
case AppEvents.WELCOME: |
||||
{ |
||||
const { user, req } = data as WelcomeEvent; |
||||
|
||||
await this.insertNotification( |
||||
{ |
||||
fk_user_id: user.id, |
||||
type: AppEvents.WELCOME, |
||||
body: {}, |
||||
}, |
||||
req, |
||||
); |
||||
} |
||||
break; |
||||
} |
||||
} |
||||
|
||||
// todo: verify if this is the right way to do it, since we are binding this context to the handler
|
||||
onModuleDestroy() { |
||||
this.appHooks.removeAllListener(this.hookHandler); |
||||
} |
||||
|
||||
onModuleInit() { |
||||
this.appHooks.onAll(this.hookHandler.bind(this)); |
||||
} |
||||
|
||||
async notificationList(param: { |
||||
user: UserType; |
||||
limit?: number; |
||||
offset?: number; |
||||
is_read?: boolean; |
||||
is_deleted?: boolean; |
||||
}) { |
||||
try { |
||||
// Define the pagination parameters
|
||||
const { limit = 10, offset = 0 } = param; // Number of rows to skip before returning results
|
||||
|
||||
const list = await Notification.list({ |
||||
fk_user_id: param.user.id, |
||||
limit, |
||||
offset, |
||||
is_deleted: false, |
||||
}); |
||||
|
||||
const count = await Notification.count({ |
||||
fk_user_id: param.user.id, |
||||
is_deleted: false, |
||||
}); |
||||
|
||||
const unreadCount = await Notification.count({ |
||||
fk_user_id: param.user.id, |
||||
is_deleted: false, |
||||
is_read: false, |
||||
}); |
||||
|
||||
return new PagedResponseImpl( |
||||
list, |
||||
{ |
||||
limit: param.limit, |
||||
offset: param.offset, |
||||
count, |
||||
}, |
||||
{ unreadCount }, |
||||
); |
||||
} catch (e) { |
||||
console.log(e); |
||||
throw e; |
||||
} |
||||
} |
||||
|
||||
async notificationUpdate(param: { notificationId: string; body; user: any }) { |
||||
await Notification.update(param.notificationId, param.body); |
||||
|
||||
return true; |
||||
} |
||||
|
||||
async markAllRead(param: { user: any }) { |
||||
if (!param.user?.id) { |
||||
NcError.badRequest('User id is required'); |
||||
} |
||||
|
||||
await Notification.markAllAsRead(param.user.id); |
||||
|
||||
return true; |
||||
} |
||||
} |
@ -0,0 +1,237 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'; |
||||
import { AppEvents } from 'nocodb-sdk'; |
||||
import type { |
||||
ProjectInviteEvent, |
||||
WelcomeEvent, |
||||
} from '~/services/app-hooks/interfaces'; |
||||
import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common'; |
||||
import type { UserType } from 'nocodb-sdk'; |
||||
import type { NcRequest } from '~/interface/config'; |
||||
import type { Response } from 'express'; |
||||
import { AppHooksService } from '~/services/app-hooks/app-hooks.service'; |
||||
import { NcError } from '~/helpers/catchError'; |
||||
import { PagedResponseImpl } from '~/helpers/PagedResponse'; |
||||
import { Notification } from '~/models'; |
||||
|
||||
import { getCircularReplacer } from '~/utils'; |
||||
import { PubSubRedis } from '~/redis/pubsub-redis'; |
||||
@Injectable() |
||||
export class NotificationsService implements OnModuleInit, OnModuleDestroy { |
||||
private logger: Logger = new Logger(NotificationsService.name); |
||||
|
||||
constructor(protected readonly appHooks: AppHooksService) {} |
||||
|
||||
connections = new Map< |
||||
string, |
||||
(Response & { |
||||
resId: string; |
||||
})[] |
||||
>(); |
||||
|
||||
addConnection = (userId: string, res: Response & { resId: string }) => { |
||||
if (!this.connections.has(userId)) { |
||||
this.connections.set(userId, [] as (Response & { resId: string })[]); |
||||
} |
||||
|
||||
this.connections.get(userId).push(res); |
||||
}; |
||||
|
||||
removeConnection = (userId: string, res: Response & { resId: string }) => { |
||||
if (!this.connections.has(userId)) { |
||||
return; |
||||
} |
||||
|
||||
const userConnections = this.connections.get(userId); |
||||
|
||||
const idx = userConnections.findIndex((c) => c.resId === res.resId); |
||||
|
||||
if (idx > -1) { |
||||
userConnections.splice(idx, 1); |
||||
} |
||||
|
||||
if (userConnections.length === 0) { |
||||
this.connections.delete(userId); |
||||
} else { |
||||
this.connections.set(userId, userConnections); |
||||
} |
||||
}; |
||||
|
||||
sendToConnections(key: string, payload: string): void { |
||||
const connections = this.connections.get(String(key)); |
||||
|
||||
for (const res of connections ?? []) { |
||||
res.send({ |
||||
status: 'success', |
||||
data: payload, |
||||
}); |
||||
} |
||||
this.removeConnectionByUserId(key); |
||||
} |
||||
|
||||
removeConnectionByUserId(userId: string) { |
||||
this.connections.delete(userId); |
||||
} |
||||
|
||||
protected async insertNotification( |
||||
insertData: Partial<Notification>, |
||||
_req: NcRequest, |
||||
) { |
||||
await Notification.insert(insertData); |
||||
|
||||
if (PubSubRedis.available) { |
||||
await PubSubRedis.publish( |
||||
`notification:${insertData.fk_user_id}`, |
||||
JSON.stringify(insertData, getCircularReplacer()), |
||||
); |
||||
} |
||||
|
||||
this.sendToConnections( |
||||
insertData.fk_user_id, |
||||
JSON.stringify(insertData, getCircularReplacer()), |
||||
); |
||||
} |
||||
|
||||
async notificationList(param: { |
||||
user: UserType; |
||||
limit?: number; |
||||
offset?: number; |
||||
is_read?: boolean; |
||||
is_deleted?: boolean; |
||||
}) { |
||||
try { |
||||
const { limit = 10, offset = 0, is_read } = param; |
||||
|
||||
const list = await Notification.list({ |
||||
fk_user_id: param.user.id, |
||||
is_read, |
||||
limit, |
||||
offset, |
||||
is_deleted: false, |
||||
}); |
||||
|
||||
const count = await Notification.count({ |
||||
fk_user_id: param.user.id, |
||||
is_deleted: false, |
||||
}); |
||||
|
||||
const unreadCount = await Notification.count({ |
||||
fk_user_id: param.user.id, |
||||
is_deleted: false, |
||||
is_read: false, |
||||
}); |
||||
|
||||
return new PagedResponseImpl( |
||||
list, |
||||
{ |
||||
limit: param.limit, |
||||
offset: param.offset, |
||||
count, |
||||
}, |
||||
{ unreadCount }, |
||||
); |
||||
} catch (e) { |
||||
this.logger.error(e); |
||||
throw e; |
||||
} |
||||
} |
||||
|
||||
async notificationUpdate(param: { |
||||
notificationId: string; |
||||
body; |
||||
user: UserType; |
||||
}) { |
||||
const notification = Notification.get({ |
||||
id: param.notificationId, |
||||
fk_user_id: param.user.id, |
||||
}); |
||||
|
||||
if (!notification) { |
||||
NcError.unauthorized('Unauthorized to update notification'); |
||||
} |
||||
await Notification.update(param.notificationId, param.body); |
||||
|
||||
return true; |
||||
} |
||||
|
||||
async notificationDelete(param: { notificationId: string; user: UserType }) { |
||||
const notification = Notification.get({ |
||||
id: param.notificationId, |
||||
fk_user_id: param.user.id, |
||||
}); |
||||
|
||||
if (!notification) { |
||||
NcError.unauthorized('Unauthorized to delete notification'); |
||||
} |
||||
|
||||
await Notification.update(param.notificationId, { |
||||
is_deleted: true, |
||||
}); |
||||
} |
||||
|
||||
async markAllRead(param: { user: UserType }) { |
||||
if (!param.user?.id) { |
||||
NcError.badRequest('User id is required'); |
||||
} |
||||
await Notification.markAllAsRead(param.user.id); |
||||
return true; |
||||
} |
||||
|
||||
protected async hookHandler({ |
||||
event, |
||||
data, |
||||
}: { |
||||
event: AppEvents; |
||||
data: ProjectInviteEvent | WelcomeEvent; |
||||
}) { |
||||
const { req } = data; |
||||
switch (event) { |
||||
case AppEvents.PROJECT_INVITE: |
||||
{ |
||||
const { base, user, invitedBy } = data as ProjectInviteEvent; |
||||
|
||||
await this.insertNotification( |
||||
{ |
||||
fk_user_id: user.id, |
||||
type: AppEvents.PROJECT_INVITE, |
||||
body: { |
||||
base: { |
||||
id: base.id, |
||||
title: base.title, |
||||
type: base.type, |
||||
}, |
||||
user: { |
||||
id: invitedBy.id, |
||||
email: invitedBy.email, |
||||
displayName: invitedBy.display_name, |
||||
}, |
||||
}, |
||||
}, |
||||
req, |
||||
); |
||||
} |
||||
break; |
||||
case AppEvents.WELCOME: |
||||
{ |
||||
const { user, req } = data as WelcomeEvent; |
||||
|
||||
await this.insertNotification( |
||||
{ |
||||
fk_user_id: user.id, |
||||
type: AppEvents.WELCOME, |
||||
body: {}, |
||||
}, |
||||
req, |
||||
); |
||||
} |
||||
break; |
||||
} |
||||
} |
||||
|
||||
onModuleDestroy() { |
||||
this.appHooks.removeAllListener(this.hookHandler); |
||||
} |
||||
|
||||
onModuleInit() { |
||||
this.appHooks.onAll(this.hookHandler.bind(this)); |
||||
} |
||||
} |
@ -0,0 +1,12 @@
|
||||
export const getCircularReplacer = () => { |
||||
const seen = new WeakSet(); |
||||
return (_, value) => { |
||||
if (typeof value === 'object' && value !== null) { |
||||
if (seen.has(value)) { |
||||
return; |
||||
} |
||||
seen.add(value); |
||||
} |
||||
return value; |
||||
}; |
||||
}; |
@ -1,5 +1,6 @@
|
||||
export * from './dataUtils'; |
||||
export * from './sanitiseUserObj'; |
||||
export * from './emailUtils'; |
||||
export * from './circularReplacer'; |
||||
|
||||
export const isEE = false; |
||||
|
@ -1,5 +1,6 @@
|
||||
packages: |
||||
- 'packages/nocodb-sdk' |
||||
- 'packages/nc-gui' |
||||
- 'packages/nc-mail-templates' |
||||
- 'packages/nocodb' |
||||
- 'tests/playwright' |
Loading…
Reference in new issue