feat: watchlist (#17947)
* lock users on signup if their email is in blacklist * add to turbo env list * prevent locked user or blocked email domain from using API * Refactored to only run 1 query to find the user * Fixing tests * WIP * WIP * WIP * Discard changes to turbo.json * Fixed tests * Update isLockedOrBlocked.test.ts * Update isAdmin.integration-test.ts * Update tsconfig.json * chore: rename to watchlist Signed-off-by: Omar López <zomars@me.com> --------- Signed-off-by: Omar López <zomars@me.com> Co-authored-by: sean-brydon <sean@cal.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
co-authored by
sean-brydon
Keith Williams
sean-brydon
parent
59993891b8
commit
1d8326ed83
@@ -6,6 +6,7 @@ import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
|
||||
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
|
||||
|
||||
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
||||
@@ -24,23 +25,35 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
if (!hasValidLicense && IS_PRODUCTION) {
|
||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
}
|
||||
// Check if the apiKey query param is provided.
|
||||
|
||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||
// remove the prefix from the user provided api_key. If no env set default to "cal_"
|
||||
|
||||
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
|
||||
// Hash the key again before matching against the database records.
|
||||
const hashedKey = hashAPIKey(strippedApiKey);
|
||||
// Check if the hashed api key exists in database.
|
||||
const apiKey = await prisma.apiKey.findUnique({ where: { hashedKey } });
|
||||
// If cannot find any api key. Throw a 401 Unauthorized.
|
||||
if (!apiKey) return res.status(401).json({ error: "Your apiKey is not valid" });
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { hashedKey },
|
||||
include: {
|
||||
user: {
|
||||
select: { role: true, locked: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!apiKey) return res.status(401).json({ error: "Your API key is not valid." });
|
||||
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
|
||||
return res.status(401).json({ error: "This apiKey is expired" });
|
||||
return res.status(401).json({ error: "This API key is expired." });
|
||||
}
|
||||
if (!apiKey.userId) return res.status(404).json({ error: "No user found for this apiKey" });
|
||||
if (!apiKey.userId || !apiKey.user)
|
||||
return res.status(404).json({ error: "No user found for this API key." });
|
||||
|
||||
// save the user id in the request for later use
|
||||
req.userId = apiKey.userId;
|
||||
req.user = apiKey.user;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
const userIsLockedOrBlocked = await isLockedOrBlocked(req);
|
||||
|
||||
if (userIsLockedOrBlocked)
|
||||
return res.status(403).json({ error: "You are not authorized to perform this request." });
|
||||
|
||||
req.isSystemWideAdmin = isAdmin && scope === ScopeOfAdmin.SystemWide;
|
||||
req.isOrganizationOwnerOrAdmin = isAdmin && scope === ScopeOfAdmin.OrgOwnerOrAdmin;
|
||||
|
||||
@@ -6,8 +6,7 @@ import { UserPermissionRole, MembershipRole } from "@calcom/prisma/enums";
|
||||
import { ScopeOfAdmin } from "./scopeOfAdmin";
|
||||
|
||||
export const isAdminGuard = async (req: NextApiRequest) => {
|
||||
const { userId } = req;
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { role: true } });
|
||||
const { user, userId } = req;
|
||||
if (!user) return { isAdmin: false, scope: null };
|
||||
|
||||
const { role: userRole } = user;
|
||||
@@ -34,7 +33,7 @@ export const isAdminGuard = async (req: NextApiRequest) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!orgOwnerOrAdminMemberships.length) return { isAdmin: false, scope: null };
|
||||
if (orgOwnerOrAdminMemberships.length > 0) return { isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin };
|
||||
|
||||
return { isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin };
|
||||
return { isAdmin: false, scope: null };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { checkIfEmailInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
|
||||
|
||||
export async function isLockedOrBlocked(req: NextApiRequest) {
|
||||
const user = req.user;
|
||||
if (!user?.email) return false;
|
||||
return user.locked || (await checkIfEmailInWatchlistController(user.email));
|
||||
}
|
||||
Vendored
+1
@@ -8,6 +8,7 @@ export declare module "next" {
|
||||
session?: Session | null;
|
||||
|
||||
userId: number;
|
||||
user?: { role: string; locked: boolean; email: string } | null;
|
||||
method: string;
|
||||
// session: { user: { id: number } };
|
||||
// query: Partial<{ [key: string]: string | string[] }>;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, vi, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||
import { hashAPIKey } from "~/../../../packages/features/ee/api-keys/lib/apiKeys";
|
||||
|
||||
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
|
||||
import { ScopeOfAdmin } from "../../../lib/utils/scopeOfAdmin";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
@@ -20,11 +20,6 @@ afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
vi.mock("@calcom/prisma");
|
||||
vi.mock("~/lib/utils/isAdmin", () => ({
|
||||
isAdminGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Verify API key", () => {
|
||||
let service: LicenseKeyService;
|
||||
|
||||
@@ -45,7 +40,6 @@ describe("Verify API key", () => {
|
||||
};
|
||||
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(false);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
|
||||
@@ -69,7 +63,6 @@ describe("Verify API key", () => {
|
||||
};
|
||||
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
|
||||
@@ -91,10 +84,18 @@ describe("Verify API key", () => {
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
|
||||
prismaMock.apiKey.findUnique.mockResolvedValue({
|
||||
id: 1,
|
||||
userId: 2,
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "admin@example.com",
|
||||
role: UserPermissionRole.ADMIN,
|
||||
locked: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = {
|
||||
@@ -102,7 +103,6 @@ describe("Verify API key", () => {
|
||||
};
|
||||
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
|
||||
@@ -125,10 +125,36 @@ describe("Verify API key", () => {
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
|
||||
prismaMock.apiKey.findUnique.mockResolvedValue({
|
||||
id: 1,
|
||||
userId: 2,
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "org-admin@acme.com",
|
||||
role: UserPermissionRole.USER,
|
||||
locked: false,
|
||||
teams: {
|
||||
create: {
|
||||
accepted: true,
|
||||
role: MembershipRole.OWNER,
|
||||
team: {
|
||||
create: {
|
||||
name: "ACME",
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
create: {
|
||||
isAdminAPIEnabled: true,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = {
|
||||
@@ -136,7 +162,6 @@ describe("Verify API key", () => {
|
||||
};
|
||||
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
|
||||
@@ -149,4 +174,44 @@ describe("Verify API key", () => {
|
||||
expect(req.isSystemWideAdmin).toBe(false);
|
||||
expect(req.isOrganizationOwnerOrAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it("should return 403 if user is locked or blocked", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {},
|
||||
query: {
|
||||
apiKey: "cal_test_key",
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "locked@example.com",
|
||||
role: UserPermissionRole.USER,
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||
|
||||
await middleware.fn(req, res, serverNext);
|
||||
|
||||
expect(middlewareSpy).toBeCalled();
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res._getData())).toEqual({ error: "You are not authorized to perform this request." });
|
||||
expect(serverNext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ describe("isAdmin guard", () => {
|
||||
});
|
||||
|
||||
req.userId = 0;
|
||||
req.user = undefined;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
|
||||
@@ -35,6 +36,7 @@ describe("isAdmin guard", () => {
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||
|
||||
req.userId = memberUser.id;
|
||||
req.user = memberUser;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
|
||||
@@ -51,6 +53,7 @@ describe("isAdmin guard", () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
|
||||
|
||||
req.userId = adminUser.id;
|
||||
req.user = adminUser;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
|
||||
@@ -67,6 +70,8 @@ describe("isAdmin guard", () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||
|
||||
req.userId = adminUser.id;
|
||||
req.user = adminUser;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
expect(isAdmin).toBe(true);
|
||||
expect(scope).toBe(ScopeOfAdmin.OrgOwnerOrAdmin);
|
||||
@@ -81,6 +86,8 @@ describe("isAdmin guard", () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-dunder@example.com" } });
|
||||
|
||||
req.userId = adminUser.id;
|
||||
req.user = adminUser;
|
||||
|
||||
const { isAdmin } = await isAdminGuard(req);
|
||||
expect(isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
|
||||
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
|
||||
|
||||
describe("isLockedOrBlocked", () => {
|
||||
beforeEach(async () => {
|
||||
await prismock.watchlist.createMany({
|
||||
data: [
|
||||
{
|
||||
type: "DOMAIN",
|
||||
value: "spam.com",
|
||||
createdById: 1,
|
||||
},
|
||||
{
|
||||
type: "DOMAIN",
|
||||
value: "blocked.com",
|
||||
createdById: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if no user in request", async () => {
|
||||
const req = { userId: null, user: null } as any;
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if user has no email", async () => {
|
||||
const req = { userId: 123, user: { email: null } } as any;
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if user is locked", async () => {
|
||||
const req = {
|
||||
userId: 123,
|
||||
user: {
|
||||
locked: true,
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if user email domain is watchlisted", async () => {
|
||||
const req = {
|
||||
userId: 123,
|
||||
user: {
|
||||
locked: false,
|
||||
email: "test@blocked.com",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if user is not locked and email domain is not watchlisted", async () => {
|
||||
const req = {
|
||||
userId: 123,
|
||||
user: {
|
||||
locked: false,
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle email domains case-insensitively", async () => {
|
||||
const req = {
|
||||
userId: 123,
|
||||
user: {
|
||||
locked: false,
|
||||
email: "test@BLOCKED.COM",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"./next.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"../../../packages/types/*.d.ts",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
|
||||
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
|
||||
import { checkIfEmailInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -38,6 +39,8 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
|
||||
})
|
||||
.parse(req.body);
|
||||
|
||||
const shouldLockByDefault = await checkIfEmailInWatchlistController(_email);
|
||||
|
||||
log.debug("handler", { email: _email });
|
||||
|
||||
let username: string | null = req.usernameStatus.requestedUserName;
|
||||
@@ -191,6 +194,7 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
locked: shouldLockByDefault,
|
||||
password: { create: { hash: hashedPassword } },
|
||||
metadata: {
|
||||
stripeCustomerId: customer.id,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { startSpan } from "@sentry/nextjs";
|
||||
|
||||
import type { Watchlist } from "../watchlist.model";
|
||||
import { WatchlistRepository } from "../watchlist.repository";
|
||||
|
||||
/**
|
||||
* Controllers use Presenters to convert the data to a UI-friendly format just before
|
||||
* returning it to the "consumer". This helps us ship less JavaScript to the client (logic
|
||||
* and libraries to convert the data), helps prevent leaking any sensitive properties, like
|
||||
* emails or hashed passwords, and also helps us slim down the amount of data we're sending
|
||||
* back to the client.
|
||||
*/
|
||||
function presenter(watchlistedEmail: Watchlist | null) {
|
||||
return startSpan({ name: "checkIfEmailInWatchlist Presenter", op: "serialize" }, () => {
|
||||
return !!watchlistedEmail;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Controllers perform authentication checks and input validation before passing the input
|
||||
* to the specific use cases. Controllers orchestrate Use Cases. They don't implement any
|
||||
* logic, but define the whole operations using use cases.
|
||||
*/
|
||||
export async function checkIfEmailInWatchlistController(
|
||||
email: string
|
||||
): Promise<ReturnType<typeof presenter>> {
|
||||
return await startSpan({ name: "checkIfEmailInWatchlist Controller" }, async () => {
|
||||
const lowercasedEmail = email.toLowerCase();
|
||||
const watchlistRepository = new WatchlistRepository();
|
||||
const watchlistedEmail = await watchlistRepository.getEmailInWatchlist(lowercasedEmail);
|
||||
return presenter(watchlistedEmail);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import { _WatchlistModel } from "@calcom/prisma/zod/watchlist";
|
||||
|
||||
export const WatchlistModelSchema = _WatchlistModel.pick({
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
createdById: true,
|
||||
updatedAt: true,
|
||||
updatedById: true,
|
||||
});
|
||||
|
||||
export type Watchlist = z.infer<typeof WatchlistModelSchema>;
|
||||
|
||||
export const insertWatchlistSchema = WatchlistModelSchema.pick({
|
||||
type: true,
|
||||
value: true,
|
||||
description: true,
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Watchlist } from "./watchlist.model";
|
||||
|
||||
export interface IWatchlistRepository {
|
||||
getEmailInWatchlist(email: string): Promise<Watchlist | null>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { WatchlistType } from "@calcom/prisma/enums";
|
||||
|
||||
import type { IWatchlistRepository } from "./watchlist.repository.interface";
|
||||
|
||||
export class MockFeaturesRepository implements IWatchlistRepository {
|
||||
async getEmailInWatchlist(email: string) {
|
||||
if (email !== "watchlisted@example.com") return null;
|
||||
return {
|
||||
id: "1",
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "watchlisted@example.com",
|
||||
description: "This is a watchlisted email",
|
||||
createdAt: new Date(),
|
||||
createdById: 1,
|
||||
updatedAt: new Date(),
|
||||
updatedById: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
|
||||
import db from "@calcom/prisma";
|
||||
import { WatchlistType } from "@calcom/prisma/enums";
|
||||
|
||||
import type { IWatchlistRepository } from "./watchlist.repository.interface";
|
||||
|
||||
export class WatchlistRepository implements IWatchlistRepository {
|
||||
async getEmailInWatchlist(email: string) {
|
||||
const [, domain] = email.split("@");
|
||||
try {
|
||||
const emailInWatchlist = await db.watchlist.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ type: WatchlistType.EMAIL, value: email },
|
||||
{ type: WatchlistType.DOMAIN, value: domain },
|
||||
],
|
||||
},
|
||||
});
|
||||
return emailInWatchlist;
|
||||
} catch (err) {
|
||||
captureException(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WatchlistType" AS ENUM ('EMAIL', 'DOMAIN', 'USERNAME');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Watchlist" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "WatchlistType" NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdById" INTEGER NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"updatedById" INTEGER,
|
||||
|
||||
CONSTRAINT "Watchlist_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Watchlist_id_key" ON "Watchlist"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Watchlist_type_value_idx" ON "Watchlist"("type", "value");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Watchlist_type_value_key" ON "Watchlist"("type", "value");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Watchlist" ADD CONSTRAINT "Watchlist_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Watchlist" ADD CONSTRAINT "Watchlist_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -357,6 +357,8 @@ model User {
|
||||
reassignedBookings Booking[] @relation("reassignByUser")
|
||||
createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations")
|
||||
updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations")
|
||||
createdWatchlists Watchlist[] @relation("CreatedWatchlists")
|
||||
updatedWatchlists Watchlist[] @relation("UpdatedWatchlists")
|
||||
|
||||
@@unique([email])
|
||||
@@unique([email, username])
|
||||
@@ -1659,3 +1661,26 @@ model EventTypeTranslation {
|
||||
@@index([eventTypeId, field, targetLang]) // TODO: remove after migration
|
||||
@@index([eventTypeId, field, targetLocale])
|
||||
}
|
||||
|
||||
enum WatchlistType {
|
||||
EMAIL
|
||||
DOMAIN
|
||||
USERNAME
|
||||
}
|
||||
|
||||
model Watchlist {
|
||||
id String @id @unique @default(cuid())
|
||||
type WatchlistType
|
||||
// The identifier of the Watchlisted entity (email or domain)
|
||||
value String
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
createdBy User @relation("CreatedWatchlists", onDelete: Cascade, fields: [createdById], references: [id])
|
||||
createdById Int
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedBy User? @relation("UpdatedWatchlists", onDelete: SetNull, fields: [updatedById], references: [id])
|
||||
updatedById Int?
|
||||
|
||||
@@unique([type, value])
|
||||
@@index([type, value])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user