diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 323ff2e..60cb331 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -223,6 +223,7 @@ export const PERMISSION_CATALOG = [ [ ['read', 'Ansehen'], ['upload', 'Hochladen'], + ['update', 'Bearbeiten'], ['delete', 'Löschen'], ], 1800, @@ -233,6 +234,7 @@ export const PERMISSION_CATALOG = [ [ ['read', 'Ansehen'], ['create', 'Anlegen'], + ['update', 'Bearbeiten'], ['delete', 'Löschen'], ], 1900, diff --git a/ApiCore-Backend/src/documents/documents.controller.ts b/ApiCore-Backend/src/documents/documents.controller.ts index 08b68be..64acc39 100644 --- a/ApiCore-Backend/src/documents/documents.controller.ts +++ b/ApiCore-Backend/src/documents/documents.controller.ts @@ -6,6 +6,7 @@ import { Get, HttpCode, Param, + Patch, Post, Query, Res, @@ -21,6 +22,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { isAllowedDocumentMimeType } from './document-mime-types'; import { CreateDocumentDto } from './dto/create-document.dto'; +import { UpdateDocumentDto } from './dto/update-document.dto'; import { DocumentsService } from './documents.service'; const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; @@ -105,6 +107,16 @@ export class DocumentsController { } @RequirePermission('documents:delete') + @RequirePermission('documents:update') + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateDocumentDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.documentsService.update(id, dto, actor.id); + } + @Delete(':id') @HttpCode(204) delete(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { diff --git a/ApiCore-Backend/src/documents/documents.service.spec.ts b/ApiCore-Backend/src/documents/documents.service.spec.ts index d6d88e0..7164b44 100644 --- a/ApiCore-Backend/src/documents/documents.service.spec.ts +++ b/ApiCore-Backend/src/documents/documents.service.spec.ts @@ -13,6 +13,7 @@ describe('DocumentsService', () => { findMany: jest.Mock; findUnique: jest.Mock; create: jest.Mock; + update: jest.Mock; delete: jest.Mock; }; apiary: { findUnique: jest.Mock }; @@ -41,6 +42,7 @@ describe('DocumentsService', () => { findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn(), create: jest.fn(), + update: jest.fn(), delete: jest.fn(), }, apiary: { findUnique: jest.fn() }, @@ -175,4 +177,63 @@ describe('DocumentsService', () => { ); }); }); + + describe('update', () => { + const existing = { + id: 'doc-1', + entityType: 'COLONY', + entityId: 'colony-1', + fileName: 'Rechnung.pdf', + mimeType: 'application/pdf', + sizeBytes: 1024, + storagePath: '/data/doc-1', + uploadedByUserId: 'user-1', + createdAt: new Date(), + }; + + it('rejects re-linking to a record that does not exist', async () => { + prisma.document.findUnique.mockResolvedValue(existing); + prisma.colony.findUnique.mockResolvedValue(null); + + await expect( + service.update('doc-1', { entityId: 'missing-colony' }, 'user-2'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.document.update).not.toHaveBeenCalled(); + }); + + it('re-links a document and logs before and after', async () => { + prisma.document.findUnique.mockResolvedValue(existing); + prisma.colony.findUnique.mockResolvedValue({ id: 'colony-2' }); + prisma.document.update.mockResolvedValue({ + ...existing, + entityId: 'colony-2', + }); + + await service.update('doc-1', { entityId: 'colony-2' }, 'user-2'); + + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Document', + entityId: 'doc-1', + action: 'UPDATE', + }), + ); + }); + + it('does not re-check the target when only the file name changes', async () => { + prisma.document.findUnique.mockResolvedValue(existing); + prisma.document.update.mockResolvedValue({ + ...existing, + fileName: 'Rechnung 2026.pdf', + }); + + await service.update( + 'doc-1', + { fileName: 'Rechnung 2026.pdf' }, + 'user-2', + ); + + expect(prisma.colony.findUnique).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ApiCore-Backend/src/documents/documents.service.ts b/ApiCore-Backend/src/documents/documents.service.ts index 7091606..7ef89bd 100644 --- a/ApiCore-Backend/src/documents/documents.service.ts +++ b/ApiCore-Backend/src/documents/documents.service.ts @@ -11,6 +11,7 @@ import { AuditLogService } from '../audit/audit-log.service'; import { matchesFileSignature } from '../common/file-signature'; import { Document, DocumentEntityType } from '../../generated/prisma'; import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types'; +import { UpdateDocumentDto } from './dto/update-document.dto'; export type DocumentSummary = Omit; @@ -139,6 +140,40 @@ export class DocumentsService { }; } + /** + * Re-links a document to a different record or corrects its display name. + * The stored file is untouched - replacing the content means uploading a + * new document. + */ + async update( + id: string, + dto: UpdateDocumentDto, + actingUserId: string, + ): Promise { + const before = await this.prisma.document.findUnique({ where: { id } }); + if (!before) { + throw new NotFoundException('Document not found'); + } + const entityType = dto.entityType ?? before.entityType; + const entityId = dto.entityId ?? before.entityId; + if (entityType !== before.entityType || entityId !== before.entityId) { + await this.assertEntityExists(entityType, entityId); + } + const document = await this.prisma.document.update({ + where: { id }, + data: { entityType, entityId, fileName: dto.fileName }, + }); + await this.auditLog.record({ + entityType: 'Document', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: toSummary(before), + afterData: toSummary(document), + }); + return toSummary(document); + } + async delete(id: string, actingUserId: string): Promise { const document = await this.prisma.document.findUnique({ where: { id }, diff --git a/ApiCore-Backend/src/documents/dto/update-document.dto.ts b/ApiCore-Backend/src/documents/dto/update-document.dto.ts new file mode 100644 index 0000000..44db93f --- /dev/null +++ b/ApiCore-Backend/src/documents/dto/update-document.dto.ts @@ -0,0 +1,27 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { DocumentEntityType } from '../../../generated/prisma'; + +/** + * A document filed against the wrong record can be re-linked instead of + * deleted and uploaded again, and its display name corrected. The stored file + * itself is not touched: replacing the content means uploading a new document. + */ +export class UpdateDocumentDto { + @ApiPropertyOptional({ enum: DocumentEntityType }) + @IsOptional() + @IsEnum(DocumentEntityType) + entityType?: DocumentEntityType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + entityId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + fileName?: string; +} diff --git a/ApiCore-Backend/src/webhooks/dto/update-webhook.dto.ts b/ApiCore-Backend/src/webhooks/dto/update-webhook.dto.ts new file mode 100644 index 0000000..0d8c87b --- /dev/null +++ b/ApiCore-Backend/src/webhooks/dto/update-webhook.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { CreateWebhookDto } from './create-webhook.dto'; + +/** + * A mistyped endpoint or a wrong event selection is correctable instead of + * forcing a delete-and-recreate, which would issue a new signing secret and + * silently break the receiver that already verifies against the old one. + * + * A changed URL goes through the same SSRF check as a new one - see + * WebhooksService.update. + */ +export class UpdateWebhookDto extends PartialType(CreateWebhookDto) { + /** + * Pausing a subscription without losing it. There was no way to set this + * after creation, so a misbehaving receiver could only be deleted. + */ + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + active?: boolean; +} diff --git a/ApiCore-Backend/src/webhooks/webhooks.controller.ts b/ApiCore-Backend/src/webhooks/webhooks.controller.ts index aa6795c..c839a42 100644 --- a/ApiCore-Backend/src/webhooks/webhooks.controller.ts +++ b/ApiCore-Backend/src/webhooks/webhooks.controller.ts @@ -5,6 +5,7 @@ import { Get, HttpCode, Param, + Patch, Post, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; @@ -12,6 +13,7 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateWebhookDto } from './dto/create-webhook.dto'; +import { UpdateWebhookDto } from './dto/update-webhook.dto'; import { WebhooksService } from './webhooks.service'; @ApiTags('webhooks') @@ -35,6 +37,16 @@ export class WebhooksController { return this.webhooksService.create(dto, actor.id); } + @RequirePermission('webhooks:update') + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateWebhookDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.webhooksService.update(id, dto, actor.id); + } + @RequirePermission('webhooks:delete') @Delete(':id') @HttpCode(204) diff --git a/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts b/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts index 18f50ab..138fb7b 100644 --- a/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts +++ b/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts @@ -1,5 +1,5 @@ import * as crypto from 'node:crypto'; -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { WebhooksService } from './webhooks.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; @@ -11,6 +11,7 @@ describe('WebhooksService', () => { findMany: jest.Mock; findUnique: jest.Mock; create: jest.Mock; + update: jest.Mock; delete: jest.Mock; }; }; @@ -23,6 +24,7 @@ describe('WebhooksService', () => { findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn(), create: jest.fn(), + update: jest.fn(), delete: jest.fn(), }, }; @@ -117,4 +119,68 @@ describe('WebhooksService', () => { service.dispatch('colony.created', { id: 'colony-1' }), ).resolves.toBeUndefined(); }); + + describe('update', () => { + const existing = { + id: 'wh-1', + url: 'https://203.0.113.10/hook', + events: ['colony.created'], + secret: 'a'.repeat(64), + createdByUserId: 'user-1', + }; + + it('applies the SSRF guard to a changed URL, so an edit is no way around it', async () => { + prisma.webhookSubscription.findUnique.mockResolvedValue(existing); + + await expect( + service.update('wh-1', { url: 'http://localhost/hook' }, 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.webhookSubscription.update).not.toHaveBeenCalled(); + }); + + it('keeps the signing secret so a working receiver does not break', async () => { + prisma.webhookSubscription.findUnique.mockResolvedValue(existing); + prisma.webhookSubscription.update.mockResolvedValue({ + ...existing, + events: ['colony.created', 'harvest.created'], + }); + + await service.update( + 'wh-1', + { events: ['colony.created', 'harvest.created'] }, + 'user-1', + ); + + const [[updateArgs]] = prisma.webhookSubscription.update.mock.calls as [ + [{ data: Record }], + ]; + expect(updateArgs.data).not.toHaveProperty('secret'); + }); + + it('keeps the secret out of the audit log', async () => { + prisma.webhookSubscription.findUnique.mockResolvedValue(existing); + prisma.webhookSubscription.update.mockResolvedValue(existing); + + await service.update('wh-1', { events: ['colony.created'] }, 'user-1'); + + const [[auditArgs]] = auditLog.record.mock.calls as [ + [ + { + beforeData: Record; + afterData: Record; + }, + ], + ]; + expect(auditArgs.beforeData).not.toHaveProperty('secret'); + expect(auditArgs.afterData).not.toHaveProperty('secret'); + }); + + it('throws NotFoundException for an unknown subscription', async () => { + prisma.webhookSubscription.findUnique.mockResolvedValue(null); + + await expect( + service.update('missing', { events: ['colony.created'] }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); }); diff --git a/ApiCore-Backend/src/webhooks/webhooks.service.ts b/ApiCore-Backend/src/webhooks/webhooks.service.ts index 9fc4a04..32910c9 100644 --- a/ApiCore-Backend/src/webhooks/webhooks.service.ts +++ b/ApiCore-Backend/src/webhooks/webhooks.service.ts @@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhookSubscription } from '../../generated/prisma'; import { CreateWebhookDto } from './dto/create-webhook.dto'; +import { UpdateWebhookDto } from './dto/update-webhook.dto'; import { WebhookEvent } from './webhook-events'; import { assertPublicWebhookUrl } from './webhook-url-guard'; @@ -57,6 +58,42 @@ export class WebhooksService { return subscription; } + /** + * Corrects an existing subscription. The signing secret deliberately stays + * the same - it identifies this subscription to the receiver, and rotating + * it on an unrelated edit would break a working integration. + */ + async update( + id: string, + dto: UpdateWebhookDto, + actingUserId: string, + ): Promise { + const before = await this.prisma.webhookSubscription.findUnique({ + where: { id }, + }); + if (!before) { + throw new NotFoundException('Webhook subscription not found'); + } + // A changed target is as dangerous as a new one: without this check an + // edit would be a way around the SSRF guard that create() applies. + if (dto.url) { + await assertPublicWebhookUrl(dto.url); + } + const subscription = await this.prisma.webhookSubscription.update({ + where: { id }, + data: { url: dto.url, events: dto.events, active: dto.active }, + }); + await this.auditLog.record({ + entityType: 'WebhookSubscription', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: this.omitSecret(before), + afterData: this.omitSecret(subscription), + }); + return subscription; + } + async delete(id: string, actingUserId: string): Promise { const subscription = await this.prisma.webhookSubscription.findUnique({ where: { id }, diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/create-webhook-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/create-webhook-dialog.tsx index 1c1aad3..64282f5 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/create-webhook-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/create-webhook-dialog.tsx @@ -6,6 +6,10 @@ import { Check, Copy } from "lucide-react"; import { toast } from "sonner"; import { createWebhook } from "@/lib/webhooks/api"; import { WEBHOOK_EVENTS, WebhookEvent } from "@/lib/webhooks/types"; +import { + WEBHOOK_EVENT_LABELS, + toggleWebhookEvent, +} from "@/lib/webhooks/labels"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -17,23 +21,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; -const EVENT_LABELS: Record = { - "colony.created": "Volk angelegt", - "inspection.created": "Durchsicht erfasst", - "queen.created": "Königin angelegt", - "queen.replaced": "Königin zugewiesen/ersetzt", - "harvest.created": "Ernte erfasst", - "batch.created": "Honigcharge angelegt", - "batch.updated": "Honigcharge geändert", - "product.stock_changed": "Lagerbestand geändert", - "task.created": "Aufgabe angelegt", - "task.completed": "Aufgabe erledigt", -}; - -function toggleEvent(events: WebhookEvent[], event: WebhookEvent, checked: boolean): WebhookEvent[] { - return checked ? [...events, event] : events.filter((entry) => entry !== event); -} - export function CreateWebhookDialog() { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); @@ -154,10 +141,12 @@ export function CreateWebhookDialog() { className="size-4.5" checked={events.includes(webhookEvent)} onChange={(inputEvent) => - setEvents((current) => toggleEvent(current, webhookEvent, inputEvent.target.checked)) + setEvents((current) => + toggleWebhookEvent(current, webhookEvent, inputEvent.target.checked), + ) } /> - {EVENT_LABELS[webhookEvent]} + {WEBHOOK_EVENT_LABELS[webhookEvent]} ))} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/edit-webhook-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/edit-webhook-dialog.tsx new file mode 100644 index 0000000..65bfced --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/edit-webhook-dialog.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { Pencil } from "lucide-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateWebhook } from "@/lib/webhooks/api"; +import { + WEBHOOK_EVENTS, + WebhookEvent, + WebhookSubscription, +} from "@/lib/webhooks/types"; +import { + WEBHOOK_EVENT_LABELS, + toggleWebhookEvent, +} from "@/lib/webhooks/labels"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +/** + * Corrects an existing subscription. Editing beats delete-and-recreate here: + * a new subscription would get a new signing secret, so the receiver that + * already verifies against the old one would silently start rejecting every + * delivery. + * + * The secret itself is never shown again after creation and is not touched by + * an edit. + */ +function EditWebhookForm({ + webhook, + onSaved, +}: { + webhook: WebhookSubscription; + onSaved: () => void; +}) { + const queryClient = useQueryClient(); + const [url, setUrl] = useState(webhook.url); + const [events, setEvents] = useState(webhook.events); + const [active, setActive] = useState(webhook.active); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: () => updateWebhook(webhook.id, { url, events, active }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["webhooks"] }); + onSaved(); + }, + onError: (mutationError: Error) => + setError( + mutationError.message || "Webhook konnte nicht gespeichert werden.", + ), + }); + + function handleSubmit(formEvent: FormEvent) { + formEvent.preventDefault(); + setError(null); + if (events.length === 0) { + setError("Bitte mindestens ein Ereignis auswählen."); + return; + } + mutation.mutate(); + } + + return ( +
+
+ + setUrl(inputEvent.target.value)} + /> +
+
+ +
+ {WEBHOOK_EVENTS.map((webhookEvent) => ( + + ))} +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + + + +
+ ); +} + +export function EditWebhookDialog({ + webhook, +}: { + webhook: WebhookSubscription; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + Bearbeiten + + } + /> + + + Webhook bearbeiten + + {open ? ( + setOpen(false)} /> + ) : null} + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/webhooks-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/webhooks-tab.tsx index f97df17..e497916 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/webhooks-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/webhooks-tab.tsx @@ -14,6 +14,7 @@ import { TableRow, } from "@/components/ui/table"; import { CreateWebhookDialog } from "./create-webhook-dialog"; +import { EditWebhookDialog } from "./edit-webhook-dialog"; export function WebhooksTab() { const queryClient = useQueryClient(); @@ -78,6 +79,8 @@ export function WebhooksTab() { +
+ deleteMutation.mutate(webhook.id)} /> +
))} diff --git a/ApiCore-Frontend/src/components/document-upload-card.tsx b/ApiCore-Frontend/src/components/document-upload-card.tsx index e26e697..1fd3ee2 100644 --- a/ApiCore-Frontend/src/components/document-upload-card.tsx +++ b/ApiCore-Frontend/src/components/document-upload-card.tsx @@ -1,11 +1,27 @@ "use client"; -import { ChangeEvent, useRef } from "react"; -import { FileText, Image as ImageIcon, Paperclip } from "lucide-react"; +import { ChangeEvent, FormEvent, useRef, useState } from "react"; +import { FileText, Image as ImageIcon, Paperclip, Pencil } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { deleteDocument, downloadDocument, fetchDocuments, uploadDocument } from "@/lib/documents/api"; -import { DocumentEntityType } from "@/lib/documents/types"; +import { + deleteDocument, + downloadDocument, + fetchDocuments, + updateDocument, + uploadDocument, +} from "@/lib/documents/api"; +import { DocumentEntityType, DocumentSummary } from "@/lib/documents/types"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/empty-state"; import { ConfirmDialog } from "@/components/confirm-dialog"; @@ -20,6 +36,68 @@ function formatFileSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } +/** + * Corrects the display name of an uploaded file. The stored content is not + * touched - replacing it means uploading a new file - but a wrong name is the + * part someone actually reads when looking for the document later. + */ +function RenameDocumentDialog({ document }: { document: DocumentSummary }) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [fileName, setFileName] = useState(document.fileName); + + const mutation = useMutation({ + mutationFn: () => updateDocument(document.id, { fileName }), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["documents", document.entityType, document.entityId], + }); + setOpen(false); + }, + onError: (error: Error) => + toast.error(error.message || "Datei konnte nicht umbenannt werden"), + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + mutation.mutate(); + } + + return ( + + + + Umbenennen + + } + /> + + + Datei umbenennen + +
+
+ + setFileName(event.target.value)} + /> +
+ + + +
+
+
+ ); +} + function DocumentTypeIcon({ mimeType }: { mimeType: string }) { return mimeType.startsWith("image/") ? ( @@ -137,6 +215,7 @@ export function DocumentUploadCard({ > Herunterladen + ("/documents", formData); } +export function updateDocument( + id: string, + input: UpdateDocumentInput, +): Promise { + return apiFetch(`/documents/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + export function deleteDocument(id: string): Promise { return apiFetch(`/documents/${id}`, { method: "DELETE" }); } diff --git a/ApiCore-Frontend/src/lib/documents/types.ts b/ApiCore-Frontend/src/lib/documents/types.ts index e1e520a..007a360 100644 --- a/ApiCore-Frontend/src/lib/documents/types.ts +++ b/ApiCore-Frontend/src/lib/documents/types.ts @@ -10,3 +10,9 @@ export interface DocumentSummary { uploadedByUserId: string | null; createdAt: string; } + +export interface UpdateDocumentInput { + entityType?: DocumentEntityType; + entityId?: string; + fileName?: string; +} diff --git a/ApiCore-Frontend/src/lib/webhooks/api.ts b/ApiCore-Frontend/src/lib/webhooks/api.ts index fc233b2..ecbb0dd 100644 --- a/ApiCore-Frontend/src/lib/webhooks/api.ts +++ b/ApiCore-Frontend/src/lib/webhooks/api.ts @@ -1,5 +1,10 @@ import { apiFetch } from "@/lib/auth/api-client"; -import { CreateWebhookInput, WebhookSubscription, WebhookSubscriptionWithSecret } from "./types"; +import { + CreateWebhookInput, + UpdateWebhookInput, + WebhookSubscription, + WebhookSubscriptionWithSecret, +} from "./types"; export function fetchWebhooks(): Promise { return apiFetch("/webhooks"); @@ -12,6 +17,16 @@ export function createWebhook(input: CreateWebhookInput): Promise { + return apiFetch(`/webhooks/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + export function deleteWebhook(id: string): Promise { return apiFetch(`/webhooks/${id}`, { method: "DELETE" }); } diff --git a/ApiCore-Frontend/src/lib/webhooks/labels.ts b/ApiCore-Frontend/src/lib/webhooks/labels.ts new file mode 100644 index 0000000..7be50c1 --- /dev/null +++ b/ApiCore-Frontend/src/lib/webhooks/labels.ts @@ -0,0 +1,24 @@ +import { WebhookEvent } from "./types"; + +export const WEBHOOK_EVENT_LABELS: Record = { + "colony.created": "Volk angelegt", + "inspection.created": "Durchsicht erfasst", + "queen.created": "Königin angelegt", + "queen.replaced": "Königin zugewiesen/ersetzt", + "harvest.created": "Ernte erfasst", + "batch.created": "Honigcharge angelegt", + "batch.updated": "Honigcharge geändert", + "product.stock_changed": "Lagerbestand geändert", + "task.created": "Aufgabe angelegt", + "task.completed": "Aufgabe erledigt", +}; + +export function toggleWebhookEvent( + events: WebhookEvent[], + event: WebhookEvent, + checked: boolean, +): WebhookEvent[] { + return checked + ? [...events, event] + : events.filter((entry) => entry !== event); +} diff --git a/ApiCore-Frontend/src/lib/webhooks/types.ts b/ApiCore-Frontend/src/lib/webhooks/types.ts index bbf00c3..2100794 100644 --- a/ApiCore-Frontend/src/lib/webhooks/types.ts +++ b/ApiCore-Frontend/src/lib/webhooks/types.ts @@ -31,3 +31,9 @@ export interface CreateWebhookInput { url: string; events: WebhookEvent[]; } + +export interface UpdateWebhookInput { + url?: string; + events?: WebhookEvent[]; + active?: boolean; +}