diff --git a/apps/api/v1/lib/helpers/verifyApiKey.ts b/apps/api/v1/lib/helpers/verifyApiKey.ts index f9832b6f9c..ebe8fd03e5 100644 --- a/apps/api/v1/lib/helpers/verifyApiKey.ts +++ b/apps/api/v1/lib/helpers/verifyApiKey.ts @@ -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; diff --git a/apps/api/v1/lib/utils/isAdmin.ts b/apps/api/v1/lib/utils/isAdmin.ts index cd551fbe6e..38f3cc3099 100644 --- a/apps/api/v1/lib/utils/isAdmin.ts +++ b/apps/api/v1/lib/utils/isAdmin.ts @@ -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 }; }; diff --git a/apps/api/v1/lib/utils/isLockedOrBlocked.ts b/apps/api/v1/lib/utils/isLockedOrBlocked.ts new file mode 100644 index 0000000000..9716503eba --- /dev/null +++ b/apps/api/v1/lib/utils/isLockedOrBlocked.ts @@ -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)); +} diff --git a/apps/api/v1/next.d.ts b/apps/api/v1/next.d.ts index a8d7fbeb1d..6816e97e4f 100644 --- a/apps/api/v1/next.d.ts +++ b/apps/api/v1/next.d.ts @@ -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[] }>; diff --git a/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts b/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts index a7214968ed..d2f06ea177 100644 --- a/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts +++ b/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts @@ -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({ + 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(); + }); }); diff --git a/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts b/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts index 0e1563d40c..2136617d23 100644 --- a/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts +++ b/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts @@ -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); }); diff --git a/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts b/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts new file mode 100644 index 0000000000..bd0e56e2cc --- /dev/null +++ b/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts @@ -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); + }); +}); diff --git a/apps/api/v1/tsconfig.json b/apps/api/v1/tsconfig.json index 9cca89adf5..ba4060d01f 100644 --- a/apps/api/v1/tsconfig.json +++ b/apps/api/v1/tsconfig.json @@ -12,6 +12,7 @@ }, "include": [ "next-env.d.ts", + "./next.d.ts", "**/*.ts", "**/*.tsx", "../../../packages/types/*.d.ts", diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts index ea6e4e7c1a..d90e6864a0 100644 --- a/packages/features/auth/signup/handlers/calcomHandler.ts +++ b/packages/features/auth/signup/handlers/calcomHandler.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, diff --git a/packages/features/watchlist/operations/check-if-email-in-watchlist.controller.ts b/packages/features/watchlist/operations/check-if-email-in-watchlist.controller.ts new file mode 100644 index 0000000000..b596bd332b --- /dev/null +++ b/packages/features/watchlist/operations/check-if-email-in-watchlist.controller.ts @@ -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> { + return await startSpan({ name: "checkIfEmailInWatchlist Controller" }, async () => { + const lowercasedEmail = email.toLowerCase(); + const watchlistRepository = new WatchlistRepository(); + const watchlistedEmail = await watchlistRepository.getEmailInWatchlist(lowercasedEmail); + return presenter(watchlistedEmail); + }); +} diff --git a/packages/features/watchlist/watchlist.model.ts b/packages/features/watchlist/watchlist.model.ts new file mode 100644 index 0000000000..6de5b9c90d --- /dev/null +++ b/packages/features/watchlist/watchlist.model.ts @@ -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; + +export const insertWatchlistSchema = WatchlistModelSchema.pick({ + type: true, + value: true, + description: true, +}); diff --git a/packages/features/watchlist/watchlist.repository.interface.ts b/packages/features/watchlist/watchlist.repository.interface.ts new file mode 100644 index 0000000000..554a0cd2b2 --- /dev/null +++ b/packages/features/watchlist/watchlist.repository.interface.ts @@ -0,0 +1,5 @@ +import type { Watchlist } from "./watchlist.model"; + +export interface IWatchlistRepository { + getEmailInWatchlist(email: string): Promise; +} diff --git a/packages/features/watchlist/watchlist.repository.mock.ts b/packages/features/watchlist/watchlist.repository.mock.ts new file mode 100644 index 0000000000..b41c215ed8 --- /dev/null +++ b/packages/features/watchlist/watchlist.repository.mock.ts @@ -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, + }; + } +} diff --git a/packages/features/watchlist/watchlist.repository.ts b/packages/features/watchlist/watchlist.repository.ts new file mode 100644 index 0000000000..5e0ebd9782 --- /dev/null +++ b/packages/features/watchlist/watchlist.repository.ts @@ -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; + } + } +} diff --git a/packages/prisma/migrations/20241202182411_add_watchlist/migration.sql b/packages/prisma/migrations/20241202182411_add_watchlist/migration.sql new file mode 100644 index 0000000000..34f264d94f --- /dev/null +++ b/packages/prisma/migrations/20241202182411_add_watchlist/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 500111cfdb..270d9e35ae 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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]) +}