Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/models/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ type Notification struct {

Date time.Time `json:"date" bson:"date"`

// Optional human-friendly name of the target entity.
// Useful for deleted entities (company/speaker) that no longer exist.
Name string `json:"name,omitempty" bson:"name,omitempty"`

// Signature is used to verify if 2 notifications are equal
Signature string `json:"signature" bson:"signature"`
}
Expand Down
6 changes: 6 additions & 0 deletions backend/src/mongodb/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ type CreateNotificationData struct {
Company *primitive.ObjectID
Meeting *primitive.ObjectID
Session *primitive.ObjectID
// Optional human-friendly name of the target entity
Name string
}

// NotifyMember adds a notification to a member
Expand All @@ -158,6 +160,7 @@ func (n *NotificationsType) NotifyMember(memberID primitive.ObjectID, data Creat
Company: data.Company,
Meeting: data.Meeting,
Session: data.Session,
Name: data.Name,
}

if err := notification.Validate(); err != nil {
Expand All @@ -181,6 +184,7 @@ func (n *NotificationsType) NotifyMember(memberID primitive.ObjectID, data Creat
"company": data.Company,
"meeting": data.Meeting,
"session": data.Session,
"name": data.Name,
"signature": signature,
"date": time.Now().UTC(),
}
Expand Down Expand Up @@ -253,6 +257,8 @@ func (n *NotificationsType) GetMemberNotifications(memberID primitive.ObjectID)
"signature": notification.Signature,
"member": notification.Member.Hex(),
"date": notification.Date.Format(time.RFC3339),
// include optional human-friendly name for deleted entities
"name": notification.Name,
}

if notification.Post != nil {
Expand Down
1 change: 1 addition & 0 deletions backend/src/router/company.go
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ func deleteCompany(w http.ResponseWriter, r *http.Request) {
mongodb.Notifications.Notify(credentials.ID, mongodb.CreateNotificationData{
Kind: models.NotificationKindDeleted,
Company: &deletedCompany.ID,
Name: deletedCompany.Name,
})
}
}
Expand Down
9 changes: 9 additions & 0 deletions backend/src/router/speaker.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ func deleteSpeaker(w http.ResponseWriter, r *http.Request) {
}

json.NewEncoder(w).Encode(deletedSpeaker)

// notify
if credentials, ok := r.Context().Value(credentialsKey).(models.AuthorizationCredentials); ok {
mongodb.Notifications.Notify(credentials.ID, mongodb.CreateNotificationData{
Kind: models.NotificationKindDeleted,
Speaker: &deletedSpeaker.ID,
Name: deletedSpeaker.Name,
})
}
}

func getSpeakerPublic(w http.ResponseWriter, r *http.Request) {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/companies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export const updateRepresentativeOrder = (
export const uploadCompanyInternalImage = (id: string, data: FormData) =>
instance.post<Company>(`/companies/${id}/image/internal`, data);

export const deleteCompany = (id: string) =>
instance.delete<Company>(`/companies/${id}`);

export interface GenerateCompanyContractData {
language: string;
eventId: number;
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/speakers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ export const createSpeaker = (data: CreateSpeakerData) =>

export const uploadSpeakerInternalImage = (id: string, data: FormData) =>
instance.post<Speaker>(`/speakers/${id}/image/internal`, data);

export const deleteSpeaker = (id: string) =>
instance.delete<Speaker>(`/speakers/${id}`);
97 changes: 88 additions & 9 deletions frontend/src/components/cards/CompanyCard.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,47 @@
<template>
<div
v-if="isDeleteConfirmOpen"
class="fixed inset-0 bg-black/20 z-40 transition-opacity duration-200"
@click="isDeleteConfirmOpen = false"
></div>
<Card class="w-full hover:shadow-lg transition-shadow duration-200">
<CardHeader>
<div class="flex items-center justify-between mb-4">
<CardTitle class="text-lg">Company Information</CardTitle>
<Button
v-if="!isEditing"
variant="outline"
size="sm"
:disabled="isUpdating"
@click="startEditing"
>
Edit
</Button>
<div class="flex items-center gap-2">
<Button
v-if="!isEditing"
variant="outline"
size="sm"
:disabled="isUpdating"
@click="startEditing"
>
Edit
</Button>
<Popover v-if="canDelete" v-model:open="isDeleteConfirmOpen">
<PopoverTrigger as-child>
<Button
variant="outline"
size="sm"
:disabled="isDeleting"
class="h-6 w-6 p-0 text-destructive hover:text-destructive"
aria-label="Delete company"
:title="isDeleting ? 'Deleting...' : 'Delete company'"
>
<TrashIcon class="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-80 z-50">
<ConfirmDelete
title="Delete Company"
:message="`Are you sure you want to delete ${company.name}? This action cannot be undone.`"
:is-deleting="isDeleting"
@cancel="isDeleteConfirmOpen = false"
@confirm="handleDelete"
/>
</PopoverContent>
</Popover>
</div>
</div>

<!-- Editing Form -->
Expand Down Expand Up @@ -95,6 +125,10 @@ import type {
} from "@/dto/companies";
import { useCompanyInfoMutation } from "@/mutations/companies";
import { useCompanyImageUploadMutation } from "@/mutations/companies";
import { deleteCompany } from "@/api/companies";
import { useAuthStore } from "@/stores/auth";
import { useQueryCache } from "@pinia/colada";
import { useRouter } from "vue-router";
import Card from "../ui/card/Card.vue";
import CardContent from "../ui/card/CardContent.vue";
import CardDescription from "../ui/card/CardDescription.vue";
Expand All @@ -104,17 +138,39 @@ import Badge from "../ui/badge/Badge.vue";
import Button from "../ui/button/Button.vue";
import Image from "../Image.vue";
import CompanyInfoForm from "../companies/CompanyInfoForm.vue";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { TrashIcon } from "lucide-vue-next";
import ConfirmDelete from "@/components/ConfirmDelete.vue";

const props = defineProps<{
company: CompanyWithParticipation;
}>();

const emit = defineEmits<{
updated: [];
deleted: [];
}>();

const isDescriptionExpanded = ref(false);
const isEditing = ref(false);
const isDeleteConfirmOpen = ref(false);
const isDeleting = ref(false);
const authStore = useAuthStore();
const queryCache = useQueryCache();
const router = useRouter();

const navigateBackWithReload = (fallback: string) => {
try {
if (window.history.length > 1) {
router.back();
setTimeout(() => window.location.reload(), 50);
} else {
router.push(fallback).then(() => window.location.reload());
}
} catch {
router.push(fallback).then(() => window.location.reload());
}
};

const companyInfoMutation = useCompanyInfoMutation();
const { mutate: updateCompanyInfo, isLoading: isUpdating } =
Expand Down Expand Up @@ -190,6 +246,29 @@ const formatWebsite = (url: string): string => {
return url;
}
};

const canDelete = computed(() => {
if (!authStore.decoded) return false;
const role = (authStore.decoded as { role?: string }).role;
return role === "COORDINATOR" || role === "ADMIN";
});

const handleDelete = async () => {
if (!props.company?.id) return;
isDeleting.value = true;
try {
await deleteCompany(props.company.id);
// Invalidate cache and navigate to list
queryCache.invalidateQueries({ key: ["companies"] });
navigateBackWithReload("/companies");
emit("deleted");
} catch (error) {
console.error("Error deleting company:", error);
} finally {
isDeleting.value = false;
isDeleteConfirmOpen.value = false;
}
};
</script>

<style scoped>
Expand Down
97 changes: 88 additions & 9 deletions frontend/src/components/cards/SpeakerCard.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,47 @@
<template>
<div
v-if="isDeleteConfirmOpen"
class="fixed inset-0 bg-black/20 z-40 transition-opacity duration-200"
@click="isDeleteConfirmOpen = false"
></div>
<Card class="w-full hover:shadow-lg transition-shadow duration-200">
<CardHeader>
<div class="flex items-center justify-between mb-4">
<CardTitle class="text-lg">Speaker Information</CardTitle>
<Button
v-if="!isEditing"
variant="outline"
size="sm"
:disabled="isUpdating"
@click="startEditing"
>
Edit
</Button>
<div class="flex items-center gap-2">
<Button
v-if="!isEditing"
variant="outline"
size="sm"
:disabled="isUpdating"
@click="startEditing"
>
Edit
</Button>
<Popover v-if="canDelete" v-model:open="isDeleteConfirmOpen">
<PopoverTrigger as-child>
<Button
variant="outline"
size="sm"
:disabled="isDeleting"
class="h-6 w-6 p-0 text-destructive hover:text-destructive"
aria-label="Delete speaker"
:title="isDeleting ? 'Deleting...' : 'Delete speaker'"
>
<TrashIcon class="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-80 z-50">
<ConfirmDelete
title="Delete Speaker"
:message="`Are you sure you want to delete ${speaker.name}? This action cannot be undone.`"
:is-deleting="isDeleting"
@cancel="isDeleteConfirmOpen = false"
@confirm="handleDelete"
/>
</PopoverContent>
</Popover>
</div>
</div>

<!-- Editing Form -->
Expand Down Expand Up @@ -95,6 +125,10 @@ import type {
} from "@/dto/speakers";
import { useSpeakerInfoMutation } from "@/mutations/speakers";
import { useSpeakerImageUploadMutation } from "@/mutations/speakers";
import { deleteSpeaker } from "@/api/speakers";
import { useAuthStore } from "@/stores/auth";
import { useQueryCache } from "@pinia/colada";
import { useRouter } from "vue-router";
import Card from "../ui/card/Card.vue";
import CardContent from "../ui/card/CardContent.vue";
import CardDescription from "../ui/card/CardDescription.vue";
Expand All @@ -104,17 +138,39 @@ import Badge from "../ui/badge/Badge.vue";
import Button from "../ui/button/Button.vue";
import Image from "../Image.vue";
import SpeakerInfoForm from "../speakers/SpeakerInfoForm.vue";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { TrashIcon } from "lucide-vue-next";
import ConfirmDelete from "@/components/ConfirmDelete.vue";

const props = defineProps<{
speaker: SpeakerWithParticipation;
}>();

const emit = defineEmits<{
updated: [];
deleted: [];
}>();

const isBioExpanded = ref(false);
const isEditing = ref(false);
const isDeleteConfirmOpen = ref(false);
const isDeleting = ref(false);
const authStore = useAuthStore();
const queryCache = useQueryCache();
const router = useRouter();

const navigateBackWithReload = (fallback: string) => {
try {
if (window.history.length > 1) {
router.back();
setTimeout(() => window.location.reload(), 50);
} else {
router.push(fallback).then(() => window.location.reload());
}
} catch {
router.push(fallback).then(() => window.location.reload());
}
};

const speakerInfoMutation = useSpeakerInfoMutation();
const { mutate: updateSpeakerInfo, isLoading: isUpdating } =
Expand Down Expand Up @@ -184,6 +240,29 @@ const shouldShowToggle = computed(() => {
const toggleBio = () => {
isBioExpanded.value = !isBioExpanded.value;
};

const canDelete = computed(() => {
if (!authStore.decoded) return false;
const role = (authStore.decoded as { role?: string }).role;
return role === "COORDINATOR" || role === "ADMIN";
});

const handleDelete = async () => {
if (!props.speaker?.id) return;
isDeleting.value = true;
try {
await deleteSpeaker(props.speaker.id);
// Invalidate cache and navigate to list
queryCache.invalidateQueries({ key: ["speakers"] });
navigateBackWithReload("/speakers");
emit("deleted");
} catch (error) {
console.error("Error deleting speaker:", error);
} finally {
isDeleting.value = false;
isDeleteConfirmOpen.value = false;
}
};
</script>

<style scoped>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/navbar/Notification.vue
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ const makeMessage = (
return "Communication deleted";
}
return "Thread deleted";
} else if (notification.name) {
return `${notification.name} was deleted`;
}
return "Deleted";
case "TAGGED":
Expand Down
1 change: 1 addition & 0 deletions frontend/src/dto/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ export interface Notification {
meeting?: string;
thread?: string;
post?: string;
name?: string; // human-friendly name of the target entity
}