mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
feat: update webhook handling and triggering
This commit is contained in:
12
apps/web/src/pages/api/webhook/trigger.ts
Normal file
12
apps/web/src/pages/api/webhook/trigger.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { handlerTriggerWebhooks } from '@documenso/lib/server-only/webhooks/trigger/handler';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
maxDuration: 300,
|
||||||
|
api: {
|
||||||
|
bodyParser: {
|
||||||
|
sizeLimit: '50mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handlerTriggerWebhooks;
|
||||||
12
packages/lib/server-only/crypto/sign.ts
Normal file
12
packages/lib/server-only/crypto/sign.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { hashString } from '../auth/hash';
|
||||||
|
import { encryptSecondaryData } from './encrypt';
|
||||||
|
|
||||||
|
export const sign = (data: unknown) => {
|
||||||
|
const stringified = JSON.stringify(data);
|
||||||
|
|
||||||
|
const hashed = hashString(stringified);
|
||||||
|
|
||||||
|
const signature = encryptSecondaryData({ data: hashed });
|
||||||
|
|
||||||
|
return signature;
|
||||||
|
};
|
||||||
12
packages/lib/server-only/crypto/verify.ts
Normal file
12
packages/lib/server-only/crypto/verify.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { hashString } from '../auth/hash';
|
||||||
|
import { decryptSecondaryData } from './decrypt';
|
||||||
|
|
||||||
|
export const verify = (data: unknown, signature: string) => {
|
||||||
|
const stringified = JSON.stringify(data);
|
||||||
|
|
||||||
|
const hashed = hashString(stringified);
|
||||||
|
|
||||||
|
const decrypted = decryptSecondaryData(signature);
|
||||||
|
|
||||||
|
return decrypted === hashed;
|
||||||
|
};
|
||||||
@ -7,7 +7,7 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { triggerWebhook } from '../../universal/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
import { sealDocument } from './seal-document';
|
import { sealDocument } from './seal-document';
|
||||||
import { sendPendingEmail } from './send-pending-email';
|
import { sendPendingEmail } from './send-pending-email';
|
||||||
|
|
||||||
@ -134,7 +134,9 @@ export const completeDocumentWithToken = async ({
|
|||||||
const updatedDocument = await getDocument({ token, documentId });
|
const updatedDocument = await getDocument({ token, documentId });
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
eventTrigger: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
||||||
documentData: updatedDocument,
|
data: updatedDocument,
|
||||||
|
userId: updatedDocument.userId,
|
||||||
|
teamId: updatedDocument.teamId ?? undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { triggerWebhook } from '../../universal/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
export type CreateDocumentOptions = {
|
export type CreateDocumentOptions = {
|
||||||
title: string;
|
title: string;
|
||||||
@ -67,8 +67,10 @@ export const createDocument = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
eventTrigger: WebhookTriggerEvents.DOCUMENT_CREATED,
|
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||||
documentData: document,
|
data: document,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return document;
|
return document;
|
||||||
|
|||||||
@ -13,10 +13,10 @@ import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
|||||||
import { signPdf } from '@documenso/signing';
|
import { signPdf } from '@documenso/signing';
|
||||||
|
|
||||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { triggerWebhook } from '../../universal/trigger-webhook';
|
|
||||||
import { getFile } from '../../universal/upload/get-file';
|
import { getFile } from '../../universal/upload/get-file';
|
||||||
import { putFile } from '../../universal/upload/put-file';
|
import { putFile } from '../../universal/upload/put-file';
|
||||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||||
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
import { sendCompletedEmail } from './send-completed-email';
|
import { sendCompletedEmail } from './send-completed-email';
|
||||||
|
|
||||||
export type SealDocumentOptions = {
|
export type SealDocumentOptions = {
|
||||||
@ -139,7 +139,9 @@ export const sealDocument = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
eventTrigger: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||||
documentData: document,
|
data: document,
|
||||||
|
userId: document.userId,
|
||||||
|
teamId: document.teamId ?? undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
RECIPIENT_ROLES_DESCRIPTION,
|
RECIPIENT_ROLES_DESCRIPTION,
|
||||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||||
} from '../../constants/recipient-roles';
|
} from '../../constants/recipient-roles';
|
||||||
import { triggerWebhook } from '../../universal/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
export type SendDocumentOptions = {
|
export type SendDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@ -189,8 +189,10 @@ export const sendDocument = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
eventTrigger: WebhookTriggerEvents.DOCUMENT_SENT,
|
event: WebhookTriggerEvents.DOCUMENT_SENT,
|
||||||
documentData: updatedDocument,
|
data: updatedDocument,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedDocument;
|
return updatedDocument;
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { ReadStatus } from '@documenso/prisma/client';
|
import { ReadStatus } from '@documenso/prisma/client';
|
||||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { triggerWebhook } from '../../universal/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
import { getDocumentAndRecipientByToken } from './get-document-by-token';
|
import { getDocumentAndRecipientByToken } from './get-document-by-token';
|
||||||
|
|
||||||
export type ViewedDocumentOptions = {
|
export type ViewedDocumentOptions = {
|
||||||
@ -59,7 +59,9 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO
|
|||||||
const document = await getDocumentAndRecipientByToken({ token });
|
const document = await getDocumentAndRecipientByToken({ token });
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
eventTrigger: WebhookTriggerEvents.DOCUMENT_OPENED,
|
event: WebhookTriggerEvents.DOCUMENT_OPENED,
|
||||||
documentData: document,
|
data: document,
|
||||||
|
userId: document.userId,
|
||||||
|
teamId: document.teamId ?? undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,16 +2,35 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
|
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type GetAllWebhooksByEventTriggerOptions = {
|
export type GetAllWebhooksByEventTriggerOptions = {
|
||||||
eventTrigger: WebhookTriggerEvents;
|
event: WebhookTriggerEvents;
|
||||||
|
userId: number;
|
||||||
|
teamId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllWebhooksByEventTrigger = async ({
|
export const getAllWebhooksByEventTrigger = async ({
|
||||||
eventTrigger,
|
event,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
}: GetAllWebhooksByEventTriggerOptions) => {
|
}: GetAllWebhooksByEventTriggerOptions) => {
|
||||||
return prisma.webhook.findMany({
|
return prisma.webhook.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
...(teamId
|
||||||
|
? {
|
||||||
|
team: {
|
||||||
|
id: teamId,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
userId,
|
||||||
|
teamId: null,
|
||||||
|
}),
|
||||||
eventTriggers: {
|
eventTriggers: {
|
||||||
has: eventTrigger,
|
has: event,
|
||||||
},
|
},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
58
packages/lib/server-only/webhooks/trigger/execute-webhook.ts
Normal file
58
packages/lib/server-only/webhooks/trigger/execute-webhook.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import {
|
||||||
|
Prisma,
|
||||||
|
type Webhook,
|
||||||
|
WebhookCallStatus,
|
||||||
|
type WebhookTriggerEvents,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type ExecuteWebhookOptions = {
|
||||||
|
event: WebhookTriggerEvents;
|
||||||
|
webhook: Webhook;
|
||||||
|
data: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeWebhook = async ({ event, webhook, data }: ExecuteWebhookOptions) => {
|
||||||
|
const { webhookUrl: url, secret } = webhook;
|
||||||
|
|
||||||
|
console.log('Executing webhook', { event, url });
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
event,
|
||||||
|
payload: data,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
webhookEndpoint: url,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Documenso-Secret': secret ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
|
||||||
|
|
||||||
|
try {
|
||||||
|
responseBody = JSON.parse(body);
|
||||||
|
} catch (err) {
|
||||||
|
responseBody = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.webhookCall.create({
|
||||||
|
data: {
|
||||||
|
url,
|
||||||
|
event,
|
||||||
|
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
|
||||||
|
requestBody: payload as Prisma.InputJsonValue,
|
||||||
|
responseCode: response.status,
|
||||||
|
responseBody,
|
||||||
|
responseHeaders: Object.fromEntries(response.headers.entries()),
|
||||||
|
webhookId: webhook.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
58
packages/lib/server-only/webhooks/trigger/handler.ts
Normal file
58
packages/lib/server-only/webhooks/trigger/handler.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { verify } from '../../crypto/verify';
|
||||||
|
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
|
||||||
|
import { executeWebhook } from './execute-webhook';
|
||||||
|
import { ZTriggerWebhookBodySchema } from './schema';
|
||||||
|
|
||||||
|
export type HandlerTriggerWebhooksResponse =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: false;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handlerTriggerWebhooks = async (
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<HandlerTriggerWebhooksResponse>,
|
||||||
|
) => {
|
||||||
|
const signature = req.headers['x-webhook-signature'];
|
||||||
|
|
||||||
|
if (typeof signature !== 'string') {
|
||||||
|
console.log('Missing signature');
|
||||||
|
return res.status(400).json({ success: false, error: 'Missing signature' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = verify(req.body, signature);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
console.log('Invalid signature');
|
||||||
|
return res.status(400).json({ success: false, error: 'Invalid signature' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = ZTriggerWebhookBodySchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.log('Invalid request body');
|
||||||
|
return res.status(400).json({ success: false, error: 'Invalid request body' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { event, data, userId, teamId } = result.data;
|
||||||
|
|
||||||
|
const allWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
allWebhooks.map(async (webhook) =>
|
||||||
|
executeWebhook({
|
||||||
|
event,
|
||||||
|
webhook,
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({ success: true, message: 'Webhooks executed successfully' });
|
||||||
|
};
|
||||||
12
packages/lib/server-only/webhooks/trigger/schema.ts
Normal file
12
packages/lib/server-only/webhooks/trigger/schema.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const ZTriggerWebhookBodySchema = z.object({
|
||||||
|
event: z.nativeEnum(WebhookTriggerEvents),
|
||||||
|
data: z.unknown(),
|
||||||
|
userId: z.number(),
|
||||||
|
teamId: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TTriggerWebhookBodySchema = z.infer<typeof ZTriggerWebhookBodySchema>;
|
||||||
40
packages/lib/server-only/webhooks/trigger/trigger-webhook.ts
Normal file
40
packages/lib/server-only/webhooks/trigger/trigger-webhook.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||||
|
import { sign } from '../../crypto/sign';
|
||||||
|
|
||||||
|
export type TriggerWebhookOptions = {
|
||||||
|
event: WebhookTriggerEvents;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
userId: number;
|
||||||
|
teamId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWebhookOptions) => {
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
event,
|
||||||
|
data,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const signature = sign(body);
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/webhook/trigger`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-webhook-signature': signature,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('Request timeout')), 500);
|
||||||
|
}),
|
||||||
|
]).catch(() => null);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to trigger webhook`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,39 +0,0 @@
|
|||||||
import type { Document, Webhook } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
export type PostWebhookPayloadOptions = {
|
|
||||||
webhookData: Pick<Webhook, 'webhookUrl' | 'secret' | 'eventTriggers'>;
|
|
||||||
documentData: Document;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const postWebhookPayload = async ({
|
|
||||||
webhookData,
|
|
||||||
documentData,
|
|
||||||
}: PostWebhookPayloadOptions) => {
|
|
||||||
const { webhookUrl, secret } = webhookData;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
event: webhookData.eventTriggers.toString(),
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
webhookEndpoint: webhookUrl,
|
|
||||||
payload: documentData,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(webhookUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Documenso-Secret': secret ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Webhook failed with the status code ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
message: 'Webhook sent successfully',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import type { Document, WebhookTriggerEvents } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { getAllWebhooksByEventTrigger } from '../server-only/webhooks/get-all-webhooks-by-event-trigger';
|
|
||||||
import { postWebhookPayload } from './post-webhook-payload';
|
|
||||||
|
|
||||||
export type TriggerWebhookOptions = {
|
|
||||||
eventTrigger: WebhookTriggerEvents;
|
|
||||||
documentData: Document;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const triggerWebhook = async ({ eventTrigger, documentData }: TriggerWebhookOptions) => {
|
|
||||||
try {
|
|
||||||
const allWebhooks = await getAllWebhooksByEventTrigger({ eventTrigger });
|
|
||||||
|
|
||||||
const webhookPromises = allWebhooks.map((webhook) => {
|
|
||||||
const { webhookUrl, secret } = webhook;
|
|
||||||
|
|
||||||
postWebhookPayload({
|
|
||||||
webhookData: { webhookUrl, secret, eventTriggers: [eventTrigger] },
|
|
||||||
documentData,
|
|
||||||
}).catch((_err) => {
|
|
||||||
throw new Error(`Failed to send webhook to ${webhookUrl}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return Promise.all(webhookPromises);
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`Failed to trigger webhook`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `event` to the `WebhookCall` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "WebhookCall" ADD COLUMN "event" "WebhookTriggerEvents" NOT NULL;
|
||||||
@ -126,16 +126,17 @@ enum WebhookCallStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model WebhookCall {
|
model WebhookCall {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
status WebhookCallStatus
|
status WebhookCallStatus
|
||||||
url String
|
url String
|
||||||
|
event WebhookTriggerEvents
|
||||||
requestBody Json
|
requestBody Json
|
||||||
responseCode Int
|
responseCode Int
|
||||||
responseHeaders Json?
|
responseHeaders Json?
|
||||||
responseBody Json?
|
responseBody Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
webhookId String
|
webhookId String
|
||||||
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
|
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ApiTokenAlgorithm {
|
enum ApiTokenAlgorithm {
|
||||||
|
|||||||
Reference in New Issue
Block a user