Skip to content

Commit c58fd3c

Browse files
committed
Merge branch 'lucas-eslint' into lucas-ci
2 parents 6a873fd + e65c720 commit c58fd3c

File tree

11 files changed

+425
-2
lines changed

11 files changed

+425
-2
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ Version 2 of eventdeck
88

99
## Documentation
1010

11-
- swagger: http://petstore.swagger.io/?url=http%3A%2F%2Flocalhost%3A8080%2Fstatic%2Fswagger.json#/auth/authCallback
11+
- swagger: make run-doc

backend/src/mongodb/notification.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,19 @@ func (n *NotificationsType) DeleteNotification(notificationID primitive.ObjectID
264264

265265
return &notification, nil
266266
}
267+
268+
// DeleteAllMemberNotifications deletes all notifications for a member
269+
func (n *NotificationsType) DeleteAllMemberNotifications(memberID primitive.ObjectID) (int64, error) {
270+
ctx = context.Background()
271+
272+
filter := bson.M{
273+
"member": memberID,
274+
}
275+
276+
deleteResult, err := n.Collection.DeleteMany(ctx, filter)
277+
if err != nil {
278+
return 0, err
279+
}
280+
281+
return deleteResult.DeletedCount, nil
282+
}

backend/src/router/init.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ func InitializeRouter() {
250250
meRouter.HandleFunc("/image", authMember(setMyImage)).Methods("POST")
251251
meRouter.HandleFunc("/notifications", authMember(getMyNotifications)).Methods("GET")
252252
meRouter.HandleFunc("/notifications/{id}", authMember(deleteMyNotification)).Methods("DELETE")
253+
meRouter.HandleFunc("/notifications", authMember(deleteAllMyNotifications)).Methods("DELETE")
253254

254255
// member handlers
255256
memberRouter := r.PathPrefix("/members").Subrouter()

backend/src/router/me.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,28 @@ func deleteMyNotification(w http.ResponseWriter, r *http.Request) {
255255

256256
json.NewEncoder(w).Encode(notification)
257257
}
258+
259+
func deleteAllMyNotifications(w http.ResponseWriter, r *http.Request) {
260+
261+
credentials, ok := r.Context().Value(credentialsKey).(models.AuthorizationCredentials)
262+
263+
if !ok {
264+
http.Error(w, "Could not parse credentials", http.StatusBadRequest)
265+
return
266+
}
267+
268+
memberID := credentials.ID
269+
270+
if _, err := mongodb.Members.GetMember(memberID); err != nil {
271+
http.Error(w, "Could not find member: " + err.Error(), http.StatusNotFound)
272+
return
273+
}
274+
275+
count, err := mongodb.Notifications.DeleteAllMemberNotifications(memberID)
276+
if err != nil {
277+
http.Error(w, "Could not delete notifications: " + err.Error(), http.StatusExpectationFailed)
278+
return
279+
}
280+
281+
json.NewEncoder(w).Encode(map[string]int64{"deletedCount": count})
282+
}

backend/static/swagger.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

backend/swagger/me-notifications.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,49 @@
3838
"description": "Unauthorized"
3939
}
4040
}
41+
},
42+
43+
"delete": {
44+
"tags": [
45+
"me",
46+
"notifications"
47+
],
48+
"summary": "Delete all my notifications",
49+
"operationId": "deleteAllMyNotifications",
50+
"consumes": [
51+
"multipart/form-data"
52+
],
53+
"produces": [
54+
"application/json"
55+
],
56+
"security": [
57+
{
58+
"Bearer": []
59+
}
60+
],
61+
"parameters": [],
62+
"responses": {
63+
"200": {
64+
"description": "Deleted notifications count",
65+
"schema": {
66+
"type": "object",
67+
"properties": {
68+
"deletedCount": {
69+
"type": "integer",
70+
"format": "int64"
71+
}
72+
}
73+
}
74+
},
75+
"417": {
76+
"description": "Unable to delete notifications"
77+
},
78+
"404": {
79+
"description": "Member not found"
80+
},
81+
"401": {
82+
"description": "Unauthorized"
83+
}
84+
}
4185
}
4286
}

frontend/src/api/notifications.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Notification } from "@/dto/notifications";
2+
import { instance } from ".";
3+
4+
export const getMyNotifications = () =>
5+
instance.get<Notification[]>("/me/notifications");
6+
export const deleteMyNotification = (id: string) =>
7+
instance.delete<Notification>(`/me/notifications/${id}`);
8+
export const deleteAllMyNotifications = () =>
9+
instance.delete<void>("/me/notifications");

frontend/src/components/Navbar.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import CompanyOrSpeakerAutocompleteWithDialog from "./CompanyOrSpeakerAutocomple
1919
import type { Company } from "@/dto/companies";
2020
import type { Speaker } from "@/dto/speakers";
2121
import { useMagicKeys } from "@vueuse/core";
22+
import Notification from "./navbar/Notification.vue";
2223
2324
const isOpen = ref(false);
2425
const authStore = useAuthStore();
@@ -136,6 +137,7 @@ watch(shortcutLinux, () => {
136137

137138
<!-- Desktop Navigation -->
138139
<div class="hidden md:flex items-center space-x-4">
140+
<Notification />
139141
<RouterLink
140142
v-for="item in navigation"
141143
:key="item.name"
@@ -159,6 +161,7 @@ watch(shortcutLinux, () => {
159161

160162
<!-- Mobile Navigation Button -->
161163
<div class="md:hidden">
164+
<Notification />
162165
<Button variant="ghost" @click="isOpen = !isOpen">
163166
<Menu v-if="!isOpen" class="h-6 w-6" />
164167
<X v-else class="h-6 w-6" />
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
<script setup lang="ts">
2+
import { computed } from "vue";
3+
import { useRouter } from "vue-router";
4+
import { useQuery } from "@pinia/colada";
5+
import { getMyNotifications } from "@/api/notifications";
6+
import { getSpeakerById } from "@/api/speakers";
7+
import { getCompanyById } from "@/api/companies";
8+
import {
9+
useDeleteNotificationMutation,
10+
useDeleteAllNotificationsMutation,
11+
} from "@/mutations/notifications";
12+
import type { EnrichedActor, EnrichedNotification } from "@/dto/notifications";
13+
import { Bell, Trash } from "lucide-vue-next";
14+
import {
15+
Popover,
16+
PopoverContent,
17+
PopoverTrigger,
18+
} from "@/components/ui/popover";
19+
import Badge from "../ui/badge/Badge.vue";
20+
import Image from "../Image.vue";
21+
22+
const makeMessage = (kind: string, actor: EnrichedActor) => {
23+
const actorName = actor.name;
24+
switch (kind) {
25+
case "UPDATED_PARTICIPATION":
26+
return "Participation updated";
27+
case "UPDATED_PARTICIPATION_STATUS":
28+
return "Participation status changed";
29+
case "CREATED_PARTICIPATION":
30+
return "New participation";
31+
case "DELETED_PARTICIPATION":
32+
return "Participation removed";
33+
case "UPLOADED_MEETING_MINUTE":
34+
return "Meeting minute uploaded";
35+
case "DELETED_MEETING_MINUTE":
36+
return "Meeting minute deleted";
37+
case "UPDATED_PRIVATE_IMAGE":
38+
return "Private image updated";
39+
case "UPDATED":
40+
return "Updated details";
41+
case "CREATED":
42+
return "Created";
43+
case "DELETED":
44+
return actorName ? `${actorName} deleted` : "Deleted";
45+
case "TAGGED":
46+
return "You were tagged in a post";
47+
default:
48+
return actorName ? `${kind} - ${actorName}` : kind;
49+
}
50+
};
51+
52+
const fetchEnrichedNotifications = async () => {
53+
const res = await getMyNotifications();
54+
const data = await Promise.all(
55+
(res.data || []).map(async (n) => {
56+
const enriched: EnrichedNotification = { ...n };
57+
try {
58+
const speakerId = n.speaker;
59+
const companyId = n.company;
60+
61+
if (speakerId) {
62+
const spRes = await getSpeakerById(speakerId);
63+
const sp = spRes?.data || spRes;
64+
if (sp) {
65+
enriched.actor = {
66+
type: "speaker",
67+
id: sp.id,
68+
name: sp.name,
69+
avatar: sp.imgs?.speaker || sp.imgs?.internal || undefined,
70+
};
71+
enriched.message = makeMessage(n.kind, enriched.actor);
72+
}
73+
} else if (companyId) {
74+
const coRes = await getCompanyById(companyId);
75+
const co = coRes?.data || coRes;
76+
if (co) {
77+
enriched.actor = {
78+
type: "company",
79+
id: co.id,
80+
name: co.name,
81+
avatar: co.imgs?.public || co.imgs?.internal || undefined,
82+
};
83+
enriched.message = makeMessage(n.kind, enriched.actor);
84+
}
85+
}
86+
} catch {
87+
// ignore enrichment errors
88+
}
89+
if (!enriched.message && enriched.actor)
90+
enriched.message = makeMessage(n.kind, enriched.actor);
91+
return enriched;
92+
}),
93+
);
94+
return { ...res, data };
95+
};
96+
97+
const { data: notifications } = useQuery({
98+
key: ["notifications"],
99+
query: fetchEnrichedNotifications,
100+
});
101+
102+
const notificationItems = computed(() => {
103+
const items = (notifications.value?.data as EnrichedNotification[]) || [];
104+
// sort newest first — try common timestamp fields (date, createdAt, created_at)
105+
return items.slice().sort((a, b) => b.date?.localeCompare(a.date ?? "") || 0);
106+
});
107+
108+
const _deleteNotificationMutation = useDeleteNotificationMutation();
109+
const _deleteAllNotificationsMutation = useDeleteAllNotificationsMutation();
110+
111+
const removeNotification = async (id: string) => {
112+
await _deleteNotificationMutation.mutate(id);
113+
};
114+
115+
const removeAllNotifications = async () => {
116+
const ids = notificationItems.value.map((i) => i.id);
117+
if (!ids.length) return;
118+
await _deleteAllNotificationsMutation.mutate();
119+
};
120+
121+
const router = useRouter();
122+
const navigateNotification = (n: EnrichedNotification) => {
123+
const actor = n.actor;
124+
if (actor?.type === "speaker" && actor?.id) {
125+
router.push({ name: "speaker", params: { speakerId: actor.id } });
126+
return;
127+
}
128+
if (actor?.type === "company" && actor?.id) {
129+
router.push({ name: "company", params: { companyId: actor.id } });
130+
return;
131+
}
132+
};
133+
134+
const onNotificationClick = async (n: EnrichedNotification) => {
135+
if (!n || !n.id) return;
136+
try {
137+
await removeNotification(n.id);
138+
} catch {
139+
// ignore
140+
}
141+
navigateNotification(n);
142+
};
143+
</script>
144+
145+
<template>
146+
<Popover>
147+
<PopoverTrigger>
148+
<button
149+
class="p-2 rounded hover:bg-gray-100 relative"
150+
:title="'Notifications'"
151+
>
152+
<Bell class="h-5 w-5 text-gray-600" />
153+
<Badge
154+
v-if="notificationItems.length"
155+
variant="destructive"
156+
class="absolute -top-2 -right-1 text-xs"
157+
>
158+
{{ notificationItems.length }}
159+
</Badge>
160+
</button>
161+
</PopoverTrigger>
162+
163+
<PopoverContent class="w-80 p-0">
164+
<div class="p-2 flex items-center justify-between border-b">
165+
<h4 class="font-semibold">Notifications</h4>
166+
<button
167+
v-if="notificationItems.length"
168+
class="text-sm text-blue-600"
169+
@click="removeAllNotifications()"
170+
>
171+
Read all
172+
</button>
173+
</div>
174+
175+
<div class="max-h-60 overflow-auto">
176+
<div v-if="!notificationItems.length" class="p-4 text-sm text-gray-500">
177+
No notifications
178+
</div>
179+
180+
<ul>
181+
<li
182+
v-for="n in notificationItems"
183+
:key="n.id"
184+
class="flex items-center justify-between px-3 py-2 hover:bg-gray-50 cursor-pointer"
185+
@click="onNotificationClick(n)"
186+
>
187+
<div class="flex items-center gap-3">
188+
<Image
189+
v-if="n.actor && n.actor.avatar"
190+
:src="n.actor.avatar"
191+
alt="actor"
192+
class="h-8 w-8 rounded-full object-cover"
193+
/>
194+
<div class="text-sm">
195+
<div class="font-medium">{{ n.message || n.kind }}</div>
196+
<div class="text-xs text-gray-500">
197+
{{ n.actor?.name || n.date }}
198+
</div>
199+
</div>
200+
</div>
201+
<div>
202+
<button
203+
class="text-red-500 text-sm"
204+
@click.stop.prevent="removeNotification(n.id)"
205+
>
206+
<Trash :size="16" />
207+
</button>
208+
</div>
209+
</li>
210+
</ul>
211+
</div>
212+
</PopoverContent>
213+
</Popover>
214+
</template>

0 commit comments

Comments
 (0)