diff --git a/apps/api/prisma/migrations/20260311172026_faq_sort_order/migration.sql b/apps/api/prisma/migrations/20260311172026_faq_sort_order/migration.sql new file mode 100644 index 00000000..50a975e9 --- /dev/null +++ b/apps/api/prisma/migrations/20260311172026_faq_sort_order/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "faq_categories" ADD COLUMN "order" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "faqs" ADD COLUMN "order" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/api/prisma/schema/Faq.prisma b/apps/api/prisma/schema/Faq.prisma index 1db11286..7b15321e 100644 --- a/apps/api/prisma/schema/Faq.prisma +++ b/apps/api/prisma/schema/Faq.prisma @@ -1,6 +1,7 @@ model FaqCategory { - id String @id @default(uuid(7)) - name String + id String @id @default(uuid(7)) + name String + order Int @default(0) unterveranstaltungId String unterveranstaltung Unterveranstaltung @relation(fields: [unterveranstaltungId], references: [id]) @@ -14,6 +15,7 @@ model Faq { id String @id @default(uuid(7)) question String answer String + order Int @default(0) categoryId String category FaqCategory @relation(fields: [categoryId], references: [id]) diff --git a/apps/api/src/services/faqs/faqCreateProcedure.ts b/apps/api/src/services/faqs/faqCreateProcedure.ts index f9df6a43..6a7f33a9 100644 --- a/apps/api/src/services/faqs/faqCreateProcedure.ts +++ b/apps/api/src/services/faqs/faqCreateProcedure.ts @@ -12,10 +12,22 @@ export const faqCreateProcedure = defineProtectedMutateProcedure({ faq: faqSchema, }), handler: async ({ input: { faq, unterveranstaltungId } }) => { + const [maxCategoryOrder, maxFaqOrder] = await Promise.all([ + prisma.faqCategory.aggregate({ + where: { unterveranstaltungId }, + _max: { order: true }, + }), + prisma.faq.aggregate({ + where: { category: { unterveranstaltungId } }, + _max: { order: true }, + }), + ]) + await prisma.faq.create({ data: { question: faq.question, answer: faq.answer, + order: (maxFaqOrder._max.order ?? -1) + 1, unterveranstaltung: { connect: { id: unterveranstaltungId, @@ -26,6 +38,7 @@ export const faqCreateProcedure = defineProtectedMutateProcedure({ create: { name: faq.category, unterveranstaltungId, + order: (maxCategoryOrder._max.order ?? -1) + 1, }, where: { name_unterveranstaltungId: { diff --git a/apps/api/src/services/faqs/faqDeleteProcecure.ts b/apps/api/src/services/faqs/faqDeleteProcecure.ts index 60258f55..1b7253eb 100644 --- a/apps/api/src/services/faqs/faqDeleteProcecure.ts +++ b/apps/api/src/services/faqs/faqDeleteProcecure.ts @@ -2,12 +2,39 @@ import z from 'zod' import prisma from '../../prisma.js' import { defineProtectedMutateProcedure } from '../../types/defineProcedure.js' +import { getGliederungRequireAdmin } from '../../util/getGliederungRequireAdmin.js' +import { TRPCError } from '@trpc/server' export const faqDeleteProcedure = defineProtectedMutateProcedure({ key: 'delete', roleIds: ['ADMIN', 'GLIEDERUNG_ADMIN'], inputSchema: z.string().uuid(), - handler: async ({ input }) => { + handler: async ({ ctx, input }) => { + const gliederung = await getGliederungRequireAdmin(ctx.accountId) + const unterveranstaltungIds = ( + await prisma.unterveranstaltung.findMany({ + where: { gliederungId: gliederung.id }, + select: { id: true }, + }) + ).map((u) => u.id) + + const faq = await prisma.faq.findUnique({ + where: { + id: input, + unterveranstaltung: { + every: { + id: { in: unterveranstaltungIds }, + }, + }, + }, + select: { + id: true, + }, + }) + if (!faq) { + throw new TRPCError({ code: 'FORBIDDEN', message: `FAQ do not belong to accounts gliederung` }) + } + await prisma.faq.delete({ where: { id: input, diff --git a/apps/api/src/services/faqs/faqListProcedure.ts b/apps/api/src/services/faqs/faqListProcedure.ts index 14c9b5ff..2a19b706 100644 --- a/apps/api/src/services/faqs/faqListProcedure.ts +++ b/apps/api/src/services/faqs/faqListProcedure.ts @@ -13,24 +13,37 @@ export async function listFaqs(unterveranstaltungId: string) { }, }, }, + orderBy: { + order: 'asc', + }, select: { id: true, question: true, answer: true, + order: true, category: { select: { + id: true, name: true, + order: true, }, }, }, }) const groups = groupBy( - list.map((v) => ({ ...v, category: v.category.name })), + list.map((v) => ({ + ...v, + category: v.category.name, + categoryId: v.category.id, + categoryOrder: v.category.order, + })), ({ category }) => category ) - return Object.fromEntries(Object.entries(groups).sort(([a], [b]) => a.localeCompare(b))) + return Object.fromEntries( + Object.entries(groups).sort(([, a], [, b]) => (a[0]?.categoryOrder ?? 0) - (b[0]?.categoryOrder ?? 0)) + ) } export const faqListProcedure = defineProtectedQueryProcedure({ diff --git a/apps/api/src/services/faqs/faqReorderProcedure.ts b/apps/api/src/services/faqs/faqReorderProcedure.ts new file mode 100644 index 00000000..4eae30a2 --- /dev/null +++ b/apps/api/src/services/faqs/faqReorderProcedure.ts @@ -0,0 +1,95 @@ +import z from 'zod' + +import prisma from '../../prisma.js' +import { TRPCError } from '@trpc/server' +import { Role } from '@prisma/client' +import { defineProtectedMutateProcedure } from '../../types/defineProcedure.js' +import { getGliederungRequireAdmin } from '../../util/getGliederungRequireAdmin.js' + +export const faqReorderProcedure = defineProtectedMutateProcedure({ + key: 'reorder', + roleIds: ['ADMIN', 'GLIEDERUNG_ADMIN'], + inputSchema: z.strictObject({ + faqOrder: z.array( + z.strictObject({ + id: z.string().uuid(), + order: z.number().int().min(0), + categoryId: z.string().uuid(), + }) + ), + categoryOrder: z.array( + z.strictObject({ + id: z.string().uuid(), + order: z.number().int().min(0), + }) + ), + }), + handler: async ({ ctx, input: { faqOrder, categoryOrder } }) => { + /// Check permissions for FAQs and categories + if (ctx.account.role !== Role.ADMIN) { + const gliederung = await getGliederungRequireAdmin(ctx.accountId) + const unterveranstaltungIds = ( + await prisma.unterveranstaltung.findMany({ + where: { gliederungId: gliederung.id }, + select: { id: true }, + }) + ).map((u) => u.id) + + const faqs = await prisma.faq.findMany({ + where: { id: { in: faqOrder.map((f) => f.id) } }, + select: { + id: true, + unterveranstaltung: { + select: { + id: true, + }, + }, + }, + }) + for (const faq of faqs) { + const unterveranstaltungId = faq.unterveranstaltung[0]?.id + if (!unterveranstaltungId || !unterveranstaltungIds.includes(unterveranstaltungId)) { + throw new TRPCError({ code: 'FORBIDDEN', message: `FAQ '${faq.id}' do not belong to accounts gliederung` }) + } + } + + const categoryIds = [...new Set([...categoryOrder.map((c) => c.id), ...faqOrder.map((f) => f.categoryId)])] + const categories = await prisma.faqCategory.findMany({ + where: { id: { in: categoryIds } }, + select: { + id: true, + unterveranstaltung: { + select: { + id: true, + }, + }, + }, + }) + for (const category of categories) { + const unterveranstaltungId = category.unterveranstaltung.id + if (!unterveranstaltungId || !unterveranstaltungIds.includes(unterveranstaltungId)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: `FAQ Category '${category.id}' do not belong to accounts gliederung`, + }) + } + } + } + + /// Perform updates in a transaction + await prisma.$transaction([ + ...faqOrder.map(({ id, order, categoryId }) => + prisma.faq.update({ + where: { id }, + data: { order, categoryId }, + }) + ), + ...categoryOrder.map(({ id, order }) => + prisma.faqCategory.update({ + where: { id }, + data: { order }, + }) + ), + ]) + }, +}) diff --git a/apps/api/src/services/faqs/faqs.router.ts b/apps/api/src/services/faqs/faqs.router.ts index 9d137081..5436996a 100644 --- a/apps/api/src/services/faqs/faqs.router.ts +++ b/apps/api/src/services/faqs/faqs.router.ts @@ -4,6 +4,7 @@ import { faqCreateProcedure } from './faqCreateProcedure.js' import { faqDeleteProcedure } from './faqDeleteProcecure.js' import { faqCategorySearchProcedure, faqListProcedure } from './faqListProcedure.js' import { faqUpdateProcedure } from './faqUpdateProcedure.js' +import { faqReorderProcedure } from './faqReorderProcedure.js' // Import Routes here - do not delete this line @@ -12,6 +13,7 @@ export const faqsRouter = mergeRouters( faqCategorySearchProcedure, faqCreateProcedure, faqUpdateProcedure, - faqDeleteProcedure + faqDeleteProcedure, + faqReorderProcedure // Add Routes here - do not delete this line ) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 773d5512..8eb5f28e 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -51,7 +51,8 @@ "vaul-vue": "^0.4.1", "vue": "catalog:", "vue-router": "^4.2.5", - "vue-sonner": "^1.3.0" + "vue-sonner": "^1.3.0", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@codeanker/eslint-config": "workspace:*", diff --git a/apps/frontend/src/views/FAQs/FAQFormModal.vue b/apps/frontend/src/views/FAQs/FAQFormModal.vue index b38162bc..b926ec93 100644 --- a/apps/frontend/src/views/FAQs/FAQFormModal.vue +++ b/apps/frontend/src/views/FAQs/FAQFormModal.vue @@ -79,6 +79,13 @@ async function onSubmit() { emit('success') } +async function onDelete() { + if (!props.faq) return + await apiClient.faq.delete.mutate(props.faq.id) + modal.value?.hide() + emit('success') +} + defineExpose({ show() { modal.value?.show() @@ -127,7 +134,16 @@ defineExpose({ /> -
+
+ +
diff --git a/apps/frontend/src/views/FAQs/FAQList.vue b/apps/frontend/src/views/FAQs/FAQList.vue index d35e067c..905f682c 100644 --- a/apps/frontend/src/views/FAQs/FAQList.vue +++ b/apps/frontend/src/views/FAQs/FAQList.vue @@ -1,9 +1,12 @@