diff --git a/prisma/migrations/15_add_share/migration.sql b/prisma/migrations/15_add_share/migration.sql new file mode 100644 index 00000000..d9f1e7cf --- /dev/null +++ b/prisma/migrations/15_add_share/migration.sql @@ -0,0 +1,41 @@ +-- CreateTable +CREATE TABLE "share" ( + "share_id" UUID NOT NULL, + "entity_id" UUID NOT NULL, + "share_type" INTEGER NOT NULL, + "slug" VARCHAR(100) NOT NULL, + "parameters" JSONB NOT NULL, + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + + CONSTRAINT "share_pkey" PRIMARY KEY ("share_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "share_share_id_key" ON "share"("share_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug"); + +-- CreateIndex +CREATE INDEX "share_entity_id_idx" ON "share"("entity_id"); + +-- MigrateData +INSERT INTO "share" (share_id, entity_id, share_type, slug, parameters, created_at) +SELECT gen_random_uuid(), + website_id, + 1, + share_id, + '{}'::jsonb, + now() +FROM "website" +WHERE share_id IS NOT NULL; + +-- DropIndex +DROP INDEX "website_share_id_idx"; + +-- DropIndex +DROP INDEX "website_share_id_key"; + +-- AlterTable +ALTER TABLE "website" DROP COLUMN "share_id"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6439b916..435a406f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6,6 +6,7 @@ generator client { datasource db { provider = "postgresql" + url = env("DATABASE_URL") relationMode = "prisma" } @@ -67,7 +68,6 @@ model Website { id String @id @unique @map("website_id") @db.Uuid name String @db.VarChar(100) domain String? @db.VarChar(500) - shareId String? @unique @map("share_id") @db.VarChar(50) resetAt DateTime? @map("reset_at") @db.Timestamptz(6) userId String? @map("user_id") @db.Uuid teamId String? @map("team_id") @db.Uuid @@ -88,7 +88,6 @@ model Website { @@index([userId]) @@index([teamId]) @@index([createdAt]) - @@index([shareId]) @@index([createdBy]) @@map("website") } @@ -339,3 +338,16 @@ model Board { @@index([createdAt]) @@map("board") } + +model Share { + id String @id() @unique() @map("share_id") @db.Uuid + entityId String @map("entity_id") @db.Uuid + shareType Int @map("share_type") @db.Integer + slug String @unique() @db.VarChar(100) + parameters Json + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@index([entityId]) + @@map("share") +} diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts index bef87c4f..da7dcf56 100644 --- a/src/app/api/share/[shareId]/route.ts +++ b/src/app/api/share/[shareId]/route.ts @@ -1,19 +1,80 @@ -import { secret } from '@/lib/crypto'; -import { createToken } from '@/lib/jwt'; -import { json, notFound } from '@/lib/response'; -import { getSharedWebsite } from '@/queries/prisma'; +import z from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, notFound, ok, unauthorized } from '@/lib/response'; +import { anyObjectParam } from '@/lib/schema'; +import { canDeleteEntity, canUpdateEntity, canViewEntity } from '@/permissions'; +import { deleteShare, getShare, updateShare } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } -export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) { const { shareId } = await params; - const website = await getSharedWebsite(shareId); + const share = await getShare(shareId); - if (!website) { + if (!(await canViewEntity(auth, share.entityId))) { + return unauthorized(); + } + + return json(share); +} + +export async function POST(request: Request, { params }: { params: Promise<{ shareId: string }> }) { + const schema = z.object({ + slug: z.string().max(100), + parameters: anyObjectParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { shareId } = await params; + const { slug, parameters } = body; + + const share = await getShare(shareId); + + if (!share) { return notFound(); } - const data = { websiteId: website.id }; - const token = createToken(data, secret()); + if (!(await canUpdateEntity(auth, share.entityId))) { + return unauthorized(); + } - return json({ ...data, token }); + const result = await updateShare(shareId, { + slug, + parameters, + } as any); + + return json(result); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ shareId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { shareId } = await params; + + const share = await getShare(shareId); + + if (!(await canDeleteEntity(auth, share.entityId))) { + return unauthorized(); + } + + await deleteShare(shareId); + + return ok(); } diff --git a/src/app/api/share/[slug]/route.ts b/src/app/api/share/[slug]/route.ts new file mode 100644 index 00000000..678795e0 --- /dev/null +++ b/src/app/api/share/[slug]/route.ts @@ -0,0 +1,19 @@ +import { secret } from '@/lib/crypto'; +import { createToken } from '@/lib/jwt'; +import { json, notFound } from '@/lib/response'; +import { getShareByCode } from '@/queries/prisma'; + +export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + + const share = await getShareByCode(slug); + + if (!share) { + return notFound(); + } + + const data = { shareId: share.id }; + const token = createToken(data, secret()); + + return json({ ...data, token }); +} diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts new file mode 100644 index 00000000..99f5df0e --- /dev/null +++ b/src/app/api/share/route.ts @@ -0,0 +1,38 @@ +import z from 'zod'; +import { uuid } from '@/lib/crypto'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { anyObjectParam } from '@/lib/schema'; +import { canUpdateEntity } from '@/permissions'; +import { createShare } from '@/queries/prisma'; + +export async function POST(request: Request) { + const schema = z.object({ + entityId: z.uuid(), + shareType: z.coerce.number().int(), + slug: z.string().max(100), + parameters: anyObjectParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { entityId, shareType, slug, parameters } = body; + + if (!(await canUpdateEntity(auth, entityId))) { + return unauthorized(); + } + + const share = await createShare({ + id: uuid(), + entityId, + shareType, + slug, + parameters, + }); + + return json(share); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 502a3df6..bfc80a13 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -95,6 +95,13 @@ export const EVENT_TYPE = { pixelEvent: 4, } as const; +export const ENTITY_TYPE = { + website: 1, + link: 2, + pixel: 3, + board: 4, +} as const; + export const DATA_TYPE = { string: 1, number: 2, diff --git a/src/lib/entity.ts b/src/lib/entity.ts new file mode 100644 index 00000000..1b64e5dd --- /dev/null +++ b/src/lib/entity.ts @@ -0,0 +1,11 @@ +import { getLink, getPixel, getWebsite } from '@/queries/prisma'; + +export async function getEntity(entityId: string) { + const website = await getWebsite(entityId); + const link = await getLink(entityId); + const pixel = await getPixel(entityId); + + const entity = website || link || pixel; + + return entity; +} diff --git a/src/permissions/entity.ts b/src/permissions/entity.ts new file mode 100644 index 00000000..a9194d2c --- /dev/null +++ b/src/permissions/entity.ts @@ -0,0 +1,65 @@ +import { hasPermission } from '@/lib/auth'; +import { PERMISSIONS } from '@/lib/constants'; +import { getEntity } from '@/lib/entity'; +import type { Auth } from '@/lib/types'; +import { getTeamUser } from '@/queries/prisma'; + +export async function canViewEntity({ user }: Auth, entityId: string) { + if (user?.isAdmin) { + return true; + } + + const entity = await getEntity(entityId); + + if (entity.userId) { + return user.id === entity.userId; + } + + if (entity.teamId) { + const teamUser = await getTeamUser(entity.teamId, user.id); + + return !!teamUser; + } + + return false; +} + +export async function canUpdateEntity({ user }: Auth, entityId: string) { + if (user.isAdmin) { + return true; + } + + const entity = await getEntity(entityId); + + if (entity.userId) { + return user.id === entity.userId; + } + + if (entity.teamId) { + const teamUser = await getTeamUser(entity.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate); + } + + return false; +} + +export async function canDeleteEntity({ user }: Auth, entityId: string) { + if (user.isAdmin) { + return true; + } + + const entity = await getEntity(entityId); + + if (entity.userId) { + return user.id === entity.userId; + } + + if (entity.teamId) { + const teamUser = await getTeamUser(entity.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete); + } + + return false; +} diff --git a/src/permissions/index.ts b/src/permissions/index.ts index 12fbca8a..c566d77c 100644 --- a/src/permissions/index.ts +++ b/src/permissions/index.ts @@ -1,4 +1,5 @@ export * from './board'; +export * from './entity'; export * from './link'; export * from './pixel'; export * from './report'; diff --git a/src/queries/prisma/index.ts b/src/queries/prisma/index.ts index 233cf671..5e975d01 100644 --- a/src/queries/prisma/index.ts +++ b/src/queries/prisma/index.ts @@ -3,6 +3,7 @@ export * from './link'; export * from './pixel'; export * from './report'; export * from './segment'; +export * from './share'; export * from './team'; export * from './teamUser'; export * from './user'; diff --git a/src/queries/prisma/share.ts b/src/queries/prisma/share.ts new file mode 100644 index 00000000..e37dc95b --- /dev/null +++ b/src/queries/prisma/share.ts @@ -0,0 +1,46 @@ +import type { Prisma } from '@/generated/prisma/client'; +import prisma from '@/lib/prisma'; + +export async function findShare(criteria: Prisma.ShareFindUniqueArgs) { + return prisma.client.share.findUnique(criteria); +} + +export async function getShare(entityId: string) { + return findShare({ + where: { + id: entityId, + }, + }); +} + +export async function getShareByCode(slug: string) { + return findShare({ + where: { + slug, + }, + }); +} + +export async function createShare( + data: Prisma.ShareCreateInput | Prisma.ShareUncheckedCreateInput, +) { + return prisma.client.share.create({ + data, + }); +} + +export async function updateShare( + shareId: string, + data: Prisma.ShareUpdateInput | Prisma.ShareUncheckedUpdateInput, +) { + return prisma.client.share.update({ + where: { + id: shareId, + }, + data, + }); +} + +export async function deleteShare(shareId: string) { + return prisma.client.share.delete({ where: { id: shareId } }); +} diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts index 6c8625d0..fe57589c 100644 --- a/src/queries/prisma/website.ts +++ b/src/queries/prisma/website.ts @@ -16,15 +16,6 @@ export async function getWebsite(websiteId: string) { }); } -export async function getSharedWebsite(shareId: string) { - return findWebsite({ - where: { - shareId, - deletedAt: null, - }, - }); -} - export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters) { const { search } = filters; const { getSearchParameters, pagedQuery } = prisma; diff --git a/src/styles/global.css b/src/styles/global.css index e9fca9fd..6e767563 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -41,3 +41,18 @@ a:hover { border: 4px solid rgba(0, 0, 0, 0); background-clip: padding-box; } + +/* Fix autofill background color to match dark theme */ +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active, +textarea:-webkit-autofill, +textarea:-webkit-autofill:hover, +textarea:-webkit-autofill:focus, +select:-webkit-autofill, +select:-webkit-autofill:hover, +select:-webkit-autofill:focus { + -webkit-box-shadow: 0 0 0 1000px var(--background-color) inset !important; + transition: color 5000s ease-in-out 0s; +}