Skip to content

Commit 524af09

Browse files
authored
feat: notify staff on task complete (#72)
* feat: notify staff on task complete * fix: logic
1 parent 5bb8a1e commit 524af09

10 files changed

Lines changed: 146 additions & 34 deletions

File tree

apps/web-app/app/app.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ await Promise.all([
5959
menu.update(),
6060
product.update(),
6161
chat.update(),
62-
notification.update(),
6362
post.update(),
6463
print.update(),
6564
activity.update(),
@@ -74,6 +73,7 @@ onMounted(async () => {
7473
user.update(),
7574
task.update(),
7675
ticket.update(),
76+
notification.update(),
7777
])
7878
7979
interval = setInterval(async () => {
@@ -82,6 +82,7 @@ onMounted(async () => {
8282
user.update(),
8383
task.update(),
8484
ticket.update(),
85+
notification.update(),
8586
])
8687
}, 30000)
8788
})

apps/web-app/app/components/Header.vue

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@
1111
<div class="flex items-center shrink-0 gap-3">
1212
<slot />
1313

14+
<UTooltip text="Уведомления" :shortcuts="['N']">
15+
<UButton
16+
color="neutral"
17+
variant="ghost"
18+
square
19+
@click="isNotificationsOpened = true"
20+
>
21+
<UChip
22+
color="error"
23+
inset
24+
:show="haveNotifications"
25+
>
26+
<UIcon name="i-lucide-bell" class="size-5 shrink-0" />
27+
</UChip>
28+
</UButton>
29+
</UTooltip>
30+
1431
<UsersOnline />
1532
</div>
1633
</div>
@@ -24,4 +41,9 @@
2441

2542
<script setup lang="ts">
2643
defineProps<{ title: string }>()
44+
45+
const { isNotificationsOpened } = useApp()
46+
47+
const notificationStore = useNotificationStore()
48+
const haveNotifications = computed(() => notificationStore.notifications.filter((notification) => !notification.viewedAt).length > 0)
2749
</script>

apps/web-app/app/components/HeaderMenuButton.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
icon="i-lucide-menu"
44
color="neutral"
55
variant="outline"
6+
square
67
@click="isNavbarOpened = true"
78
/>
89
</template>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<template>
2+
<UDrawer v-model:open="isNotificationsOpened" direction="right">
3+
<template #content>
4+
<div class="p-4 min-w-80 max-w-120 min-h-96 size-full overflow-auto">
5+
<div class="flex flex-col gap-2.5">
6+
<UCard
7+
v-for="notification in notificationStore.notifications"
8+
:key="notification.id"
9+
>
10+
<div class="flex flex-col gap-3">
11+
<div class="flex flex-row gap-3 items-center">
12+
<UChip
13+
color="error"
14+
:show="!notification.viewedAt"
15+
inset
16+
>
17+
<UAvatar
18+
:src="notification.author.avatarUrl ?? undefined"
19+
size="lg"
20+
/>
21+
</UChip>
22+
23+
<div class="flex flex-col gap-0.5">
24+
<h3 class="text-lg/6 font-semibold">
25+
{{ notification.author.name }} {{ notification.author.surname }}
26+
</h3>
27+
28+
<time
29+
:datetime="notification.createdAt"
30+
class="text-sm text-muted"
31+
v-text="format(new Date(notification.createdAt), 'd MMMM в HH:mm', { locale: ru })"
32+
/>
33+
</div>
34+
</div>
35+
36+
<div class="flex flex-col gap-2">
37+
<p class="text-base/5 font-medium whitespace-pre-wrap">
38+
{{ notification.title }}
39+
</p>
40+
41+
<p class="text-sm text-dimmed">
42+
{{ notification.description }}
43+
</p>
44+
</div>
45+
</div>
46+
</UCard>
47+
</div>
48+
</div>
49+
</template>
50+
</UDrawer>
51+
</template>
52+
53+
<script setup lang="ts">
54+
import { format } from 'date-fns'
55+
import { ru } from 'date-fns/locale/ru'
56+
57+
const { isNotificationsOpened } = useApp()
58+
59+
const notificationStore = useNotificationStore()
60+
</script>

apps/web-app/app/composables/useApp.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@ function _useApp() {
22
const route = useRoute()
33

44
const isNavbarOpened = ref(false)
5+
const isNotificationsOpened = ref(false)
56
const imagesMode = ref<'color' | 'grayscale'>('color')
67

78
watch(
89
() => route.fullPath,
910
() => {
1011
isNavbarOpened.value = false
12+
isNotificationsOpened.value = false
1113
},
1214
)
1315

1416
return {
1517
isNavbarOpened,
18+
isNotificationsOpened,
1619
imagesMode,
1720
}
1821
}

apps/web-app/app/layouts/default.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
</main>
66
</div>
77

8+
<NotificationsDrawer />
9+
810
<USlideover
911
v-model:open="isNavbarOpened"
1012
side="left"

apps/web-app/app/stores/notification.ts

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
import type { Notification, Task, User } from '@roll-stack/database'
22

3-
type NotificationWithEntities = Notification & {
4-
task: Task | null
3+
type TaskWithPerformer = Task & {
4+
performer: User | null
55
}
66

7-
type NotificationWithTask = Notification & {
8-
task: (Task & {
9-
performer: User
10-
})
7+
type NotificationWithEntities = Notification & {
8+
task: TaskWithPerformer | null
9+
author: User
1110
}
1211

1312
export const useNotificationStore = defineStore('notification', () => {
1413
const notifications = ref<NotificationWithEntities[]>([])
1514

16-
const interval = ref<NodeJS.Timeout | undefined>(undefined)
1715
const toastContext = useToast()
1816

1917
async function update() {
@@ -36,13 +34,13 @@ export const useNotificationStore = defineStore('notification', () => {
3634
}
3735
}
3836

39-
function showCompletedTaskToast(notification: NotificationWithTask) {
37+
function _showCompletedTaskToast(notification: NotificationWithEntities) {
4038
toastContext.add({
4139
id: notification.id,
4240
title: notification.title,
43-
description: notification.description,
41+
description: notification.description ?? '',
4442
avatar: {
45-
src: notification.task.performer.avatarUrl ?? undefined,
43+
src: notification.task?.performer?.avatarUrl ?? undefined,
4644
alt: '',
4745
},
4846
color: 'info',
@@ -97,26 +95,18 @@ export const useNotificationStore = defineStore('notification', () => {
9795
// }, 3000)
9896
}
9997

100-
watch(notifications, () => {
101-
for (const notification of notifications.value) {
102-
// already shown?
103-
if (toastContext.toasts.value.find((toast) => toast.id === notification.id)) {
104-
continue
105-
}
106-
107-
if (notification.type === 'task_completed') {
108-
showCompletedTaskToast(notification as NotificationWithTask)
109-
}
110-
}
111-
})
112-
113-
onMounted(() => {
114-
interval.value = setInterval(() => update(), 30000)
115-
})
98+
// watch(notifications, () => {
99+
// for (const notification of notifications.value) {
100+
// // already shown?
101+
// if (toastContext.toasts.value.find((toast) => toast.id === notification.id)) {
102+
// continue
103+
// }
116104

117-
onUnmounted(() => {
118-
clearInterval(interval.value)
119-
})
105+
// if (notification.type === 'task_completed') {
106+
// showCompletedTaskToast(notification)
107+
// }
108+
// }
109+
// })
120110

121111
return {
122112
notifications,

apps/web-app/server/api/task/id/[taskId]/complete.post.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,23 @@ export default defineEventHandler(async (event) => {
9494
}
9595
}
9696

97+
// Notify all staff
98+
if (user.type === 'staff') {
99+
const users = await repository.user.list()
100+
const allStaffExceptUser = users.filter((u) => u.type === 'staff' && u.id !== user.id)
101+
102+
for (const staff of allStaffExceptUser) {
103+
await repository.notification.create({
104+
authorId: user.id,
105+
userId: staff.id,
106+
taskId: updatedTask.id,
107+
type: 'task_completed',
108+
title: `${suffixByGender(['Завершил', 'Завершила'], user.gender)} задачу «${updatedTask.name}»`,
109+
description: updatedTask.report ? updatedTask.report : 'Без отчета',
110+
})
111+
}
112+
}
113+
97114
return { ok: true }
98115
} catch (error) {
99116
throw errorResolver(error)

packages/database/src/repository/notification.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { NotificationDraft } from '../types'
2-
import { eq } from 'drizzle-orm'
2+
import { eq, sql } from 'drizzle-orm'
33
import { useDatabase } from '../database'
44
import { notifications } from '../tables'
55

@@ -13,7 +13,10 @@ export class Notification {
1313
static async listByUser(userId: string) {
1414
return useDatabase().query.notifications.findMany({
1515
where: (notifications, { eq }) => eq(notifications.userId, userId),
16+
orderBy: (notifications, { desc }) => desc(notifications.createdAt),
17+
limit: 250,
1618
with: {
19+
author: true,
1720
task: {
1821
with: {
1922
performer: true,
@@ -31,7 +34,10 @@ export class Notification {
3134
static async update(id: string, data: Partial<NotificationDraft>) {
3235
const [notification] = await useDatabase()
3336
.update(notifications)
34-
.set(data)
37+
.set({
38+
...data,
39+
updatedAt: sql`now()`,
40+
})
3541
.where(eq(notifications.id, id))
3642
.returning()
3743
return notification

packages/database/src/tables.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,14 @@ export const notifications = pgTable('notifications', {
368368
id: cuid2('id').defaultRandom().primaryKey(),
369369
createdAt: timestamp('created_at', { precision: 3, withTimezone: true, mode: 'string' }).notNull().defaultNow(),
370370
updatedAt: timestamp('updated_at', { precision: 3, withTimezone: true, mode: 'string' }).notNull().defaultNow(),
371+
viewedAt: timestamp('viewed_at', { precision: 3, withTimezone: true, mode: 'string' }),
371372
type: varchar('type').notNull().$type<NotificationType>(),
372373
title: varchar('title').notNull(),
373-
description: varchar('description').notNull(),
374+
description: varchar('description'),
375+
authorId: cuid2('author_id').notNull().references(() => users.id, {
376+
onDelete: 'cascade',
377+
onUpdate: 'cascade',
378+
}),
374379
userId: cuid2('user_id').notNull().references(() => users.id, {
375380
onDelete: 'cascade',
376381
onUpdate: 'cascade',
@@ -859,7 +864,8 @@ export const mediaItemRelations = relations(mediaItems, ({ one }) => ({
859864
}),
860865
}))
861866

862-
export const taskRelations = relations(tasks, ({ one }) => ({
867+
export const taskRelations = relations(tasks, ({ one, many }) => ({
868+
notifications: many(notifications),
863869
performer: one(users, {
864870
fields: [tasks.performerId],
865871
references: [users.id],
@@ -879,6 +885,10 @@ export const taskListRelations = relations(taskLists, ({ many, one }) => ({
879885
}))
880886

881887
export const notificationRelations = relations(notifications, ({ one }) => ({
888+
author: one(users, {
889+
fields: [notifications.authorId],
890+
references: [users.id],
891+
}),
882892
user: one(users, {
883893
fields: [notifications.userId],
884894
references: [users.id],

0 commit comments

Comments
 (0)