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
4 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"> |
<script setup lang="ts"> |
||||||
|
import type { WelcomeEventType } from 'nocodb-sdk' |
||||||
|
|
||||||
const props = defineProps<{ |
const props = defineProps<{ |
||||||
item: any |
item: WelcomeEventType |
||||||
}>() |
}>() |
||||||
|
|
||||||
const router = useRouter() |
|
||||||
const route = router.currentRoute |
|
||||||
|
|
||||||
const item = toRef(props, 'item') |
const item = toRef(props, 'item') |
||||||
|
|
||||||
const navigateToHome = () => { |
|
||||||
if (route.value.path !== '/') { |
|
||||||
navigateTo(`/`) |
|
||||||
} |
|
||||||
} |
|
||||||
</script> |
</script> |
||||||
|
|
||||||
<template> |
<template> |
||||||
<NotificationItemWrapper :item="item" @click="navigateToHome"> |
<NotificationItemWrapper :item="item"> |
||||||
<template #avatar> |
<div>Welcome to <span class="font-semibold">NocoDB!</span> We’re excited to have you onboard.</div> |
||||||
<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> |
</NotificationItemWrapper> |
||||||
</template> |
</template> |
||||||
|
@ -1,94 +1,73 @@ |
|||||||
<script setup lang="ts"> |
<script setup lang="ts"> |
||||||
import { timeAgo } from 'nocodb-sdk' |
import type { NotificationType } from 'nocodb-sdk' |
||||||
|
import { timeAgo } from '~/utils/datetimeUtils' |
||||||
|
|
||||||
const props = defineProps<{ |
const props = defineProps<{ |
||||||
item: { |
item: NotificationType |
||||||
created_at: any |
|
||||||
} |
|
||||||
}>() |
}>() |
||||||
|
|
||||||
const item = toRef(props, 'item') |
const item = toRef(props, 'item') |
||||||
|
|
||||||
|
const { isMobileMode } = useGlobal() |
||||||
|
|
||||||
const notificationStore = useNotification() |
const notificationStore = useNotification() |
||||||
|
|
||||||
const { markAsRead } = notificationStore |
const { toggleRead, deleteNotification } = notificationStore |
||||||
</script> |
</script> |
||||||
|
|
||||||
<template> |
<template> |
||||||
<div |
<div class="flex pl-6 pr-4 w-full overflow-x-hidden group py-4 hover:bg-gray-50 gap-3 relative cursor-pointer"> |
||||||
class="flex items-center gap-1 cursor-pointer nc-notification-item-wrapper" |
<div class="w-9.625"> |
||||||
:class="{ |
|
||||||
active: !item.is_read, |
|
||||||
}" |
|
||||||
> |
|
||||||
<div class="nc-notification-dot" :class="{ active: !item.is_read }"></div> |
|
||||||
<div class="nc-avatar-wrapper"> |
|
||||||
<slot name="avatar"> |
<slot name="avatar"> |
||||||
<div class="nc-notification-avatar"></div> |
<img src="~assets/img/brand/nocodb-logo.svg" alt="NocoDB" class="w-8" /> |
||||||
</slot> |
</slot> |
||||||
</div> |
</div> |
||||||
<div class="flex-grow ml-3"> |
|
||||||
<div class="flex items-center"> |
<div class="text-[13px] min-h-12 w-full leading-5"> |
||||||
<slot /> |
<slot /> |
||||||
</div> |
</div> |
||||||
<div |
<div v-if="item" class="text-xs whitespace-nowrap absolute right-4.1 bottom-5 text-gray-600"> |
||||||
v-if="item" |
{{ timeAgo(item.created_at) }} |
||||||
class="text-xs text-gray-500 mt-1" |
</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="{ |
:class="{ |
||||||
'text-primary': !item.is_read, |
'!opacity-100': isMobileMode, |
||||||
}" |
}" |
||||||
|
class="transition-all duration-100 opacity-0 !group-hover:opacity-100" |
||||||
> |
> |
||||||
{{ timeAgo(item.created_at) }} |
<NcButton size="xsmall" type="secondary" @click.stop> |
||||||
</div> |
<GeneralIcon icon="threeDotVertical" /> |
||||||
</div> |
</NcButton> |
||||||
<div @click.stop> |
|
||||||
<a-dropdown> |
|
||||||
<GeneralIcon v-if="!item.is_read" icon="threeDotVertical" class="nc-notification-menu-icon" /> |
|
||||||
<template #overlay> |
<template #overlay> |
||||||
<a-menu> |
<NcMenu> |
||||||
<a-menu-item @click="markAsRead(item)"> |
<NcMenuItem @click.stop="() => toggleRead(item)"> Mark as unread </NcMenuItem> |
||||||
<div class="p-2 text-xs">Mark as read</div> |
<NcDivider /> |
||||||
</a-menu-item> |
<NcMenuItem class="!text-red-500 !hover:bg-red-50" @click.stop="deleteNotification(item)"> Delete </NcMenuItem> |
||||||
</a-menu> |
</NcMenu> |
||||||
</template> |
</template> |
||||||
</a-dropdown> |
</NcDropdown> |
||||||
</div> |
</div> |
||||||
</div> |
</div> |
||||||
</template> |
</template> |
||||||
|
|
||||||
<style scoped lang="scss"> |
<style scoped lang="scss"></style> |
||||||
.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> |
|
||||||
|
@ -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 './dataUtils'; |
||||||
export * from './sanitiseUserObj'; |
export * from './sanitiseUserObj'; |
||||||
export * from './emailUtils'; |
export * from './emailUtils'; |
||||||
|
export * from './circularReplacer'; |
||||||
|
|
||||||
export const isEE = false; |
export const isEE = false; |
||||||
|
@ -1,5 +1,6 @@ |
|||||||
packages: |
packages: |
||||||
- 'packages/nocodb-sdk' |
- 'packages/nocodb-sdk' |
||||||
- 'packages/nc-gui' |
- 'packages/nc-gui' |
||||||
|
- 'packages/nc-mail-templates' |
||||||
- 'packages/nocodb' |
- 'packages/nocodb' |
||||||
- 'tests/playwright' |
- 'tests/playwright' |
Loading…
Reference in new issue