diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c06b1d..8202cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to `doc` are documented here. ### Fixed +- Persist like identity per viewer using a server-side `PubDocLike` table and cookie-based + anonymous viewer token. Duplicate likes from the same viewer are now idempotent, cancel + only succeeds if that viewer holds a like, and counts survive reload across devices. - Add an in-app back button to the TopBar that appears only after the first in-app navigation, and scope the entry-document flag to `sessionStorage` so it survives SPA navigation but resets on full page reload (#66). diff --git a/prisma/migrations/20260918000000_add_pub_doc_like/migration.sql b/prisma/migrations/20260918000000_add_pub_doc_like/migration.sql new file mode 100644 index 0000000..fa447d0 --- /dev/null +++ b/prisma/migrations/20260918000000_add_pub_doc_like/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "PubDocLike" ( + "id" TEXT NOT NULL, + "viewerId" TEXT NOT NULL, + "pubDocId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PubDocLike_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "PubDocLike_pubDocId_idx" ON "PubDocLike"("pubDocId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PubDocLike_viewerId_pubDocId_key" ON "PubDocLike"("viewerId", "pubDocId"); + +-- AddForeignKey +ALTER TABLE "PubDocLike" ADD CONSTRAINT "PubDocLike_pubDocId_fkey" FOREIGN KEY ("pubDocId") REFERENCES "PubDoc"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index af21a3a..b2e8029 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -200,6 +200,19 @@ model PubDoc { user User @relation(fields: [userId], references: [id], name: "pubDocs") doc Doc @relation(fields: [docId], references: [id], onDelete: Cascade) thumbUpCount Int @default(0) + likes PubDocLike[] +} + +model PubDocLike { + id String @id @default(cuid()) + viewerId String + pubDocId String + createdAt DateTime @default(now()) + + pubDoc PubDoc @relation(fields: [pubDocId], references: [id], onDelete: Cascade) + + @@unique([viewerId, pubDocId]) + @@index([pubDocId]) } model AuditLog { diff --git a/src/__tests__/api/pub-like-viewer.test.ts b/src/__tests__/api/pub-like-viewer.test.ts new file mode 100644 index 0000000..7ce4482 --- /dev/null +++ b/src/__tests__/api/pub-like-viewer.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveViewerId: vi.fn(), + pubFindUnique: vi.fn(), + likeFindUnique: vi.fn(), + likeCreate: vi.fn(), + likeDeleteMany: vi.fn(), + pubUpdate: vi.fn(), + pubUpdateMany: vi.fn(), + transaction: vi.fn(), +})) + +vi.mock('server-only', () => ({})) +vi.mock('@/lib/viewer-id', () => ({ resolveViewerId: mocks.resolveViewerId })) +vi.mock('@/db/db', () => ({ + db: { + pubDoc: { + findUnique: mocks.pubFindUnique, + update: mocks.pubUpdate, + updateMany: mocks.pubUpdateMany, + }, + pubDocLike: { + findUnique: mocks.likeFindUnique, + create: mocks.likeCreate, + deleteMany: mocks.likeDeleteMany, + }, + $transaction: mocks.transaction, + }, +})) + +import { GET, PATCH as increase } from '@/app/api/pub/thumb-up/[publishId]/route' +import { PATCH as decrease } from '@/app/api/pub/thumb-up-decrease/[publishId]/route' + +const params = { publishId: 'pub-1' } +const request = new Request('http://doc.test/api/pub/thumb-up/pub-1', { method: 'PATCH' }) + +describe('published like GET/PATCH', () => { + beforeEach(() => { + Object.values(mocks).forEach((mock) => mock.mockReset()) + mocks.resolveViewerId.mockResolvedValue({ viewerId: 'anon-1', setCookie: false }) + mocks.transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => + fn({ + pubDoc: { + findUnique: mocks.pubFindUnique, + update: mocks.pubUpdate, + updateMany: mocks.pubUpdateMany, + }, + pubDocLike: { + create: mocks.likeCreate, + deleteMany: mocks.likeDeleteMany, + }, + }) + ) + }) + + it('GET reports whether this viewer currently holds a like', async () => { + mocks.pubFindUnique.mockResolvedValue({ id: 'doc-pub', thumbUpCount: 3 }) + mocks.likeFindUnique.mockResolvedValue({ id: 'like-1' }) + + const response = await GET(request, { params }) + await expect(response.json()).resolves.toEqual({ + errno: 0, + data: { liked: true, count: 3 }, + }) + }) + + it('PATCH increase creates the like row before incrementing', async () => { + mocks.pubFindUnique.mockResolvedValue({ id: 'doc-pub', thumbUpCount: 0 }) + mocks.likeCreate.mockResolvedValue({ id: 'like-1' }) + mocks.pubUpdate.mockResolvedValue({ thumbUpCount: 1 }) + + const response = await increase(request, { params }) + await expect(response.json()).resolves.toEqual({ + errno: 0, + data: { liked: true, count: 1 }, + }) + expect(mocks.likeCreate).toHaveBeenCalledWith({ + data: { viewerId: 'anon-1', pubDocId: 'doc-pub' }, + }) + expect(mocks.pubUpdate).toHaveBeenCalledWith({ + where: { publishId: 'pub-1' }, + data: { thumbUpCount: { increment: 1 } }, + select: { thumbUpCount: true }, + }) + }) + + it('PATCH increase is idempotent when create hits the unique constraint', async () => { + mocks.pubFindUnique + .mockResolvedValueOnce({ id: 'doc-pub', thumbUpCount: 1 }) + .mockResolvedValueOnce({ id: 'doc-pub', thumbUpCount: 1 }) + mocks.likeCreate.mockRejectedValue({ code: 'P2002' }) + + const response = await increase(request, { params }) + await expect(response.json()).resolves.toEqual({ + errno: 0, + data: { liked: true, count: 1 }, + }) + expect(mocks.pubUpdate).not.toHaveBeenCalled() + }) + + it('PATCH decrease deletes and decrements in one transaction', async () => { + mocks.pubFindUnique + .mockResolvedValueOnce({ id: 'doc-pub', thumbUpCount: 2 }) + .mockResolvedValueOnce({ id: 'doc-pub', thumbUpCount: 1 }) + mocks.likeDeleteMany.mockResolvedValue({ count: 1 }) + mocks.pubUpdateMany.mockResolvedValue({ count: 1 }) + + const response = await decrease(request, { params }) + await expect(response.json()).resolves.toEqual({ + errno: 0, + data: { liked: false, count: 1 }, + }) + expect(mocks.likeDeleteMany).toHaveBeenCalledWith({ + where: { viewerId: 'anon-1', pubDocId: 'doc-pub' }, + }) + expect(mocks.pubUpdateMany).toHaveBeenCalledWith({ + where: { publishId: 'pub-1', thumbUpCount: { gt: 0 } }, + data: { thumbUpCount: { decrement: 1 } }, + }) + }) + + it('PATCH decrease does not decrement when this viewer has no like', async () => { + mocks.pubFindUnique.mockResolvedValue({ id: 'doc-pub', thumbUpCount: 2 }) + mocks.likeDeleteMany.mockResolvedValue({ count: 0 }) + + const response = await decrease(request, { params }) + await expect(response.json()).resolves.toEqual({ + errno: 0, + data: { liked: false, count: 2 }, + }) + expect(mocks.pubUpdateMany).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/api/pub/thumb-up-decrease/[publishId]/route.ts b/src/app/api/pub/thumb-up-decrease/[publishId]/route.ts index 8be9b83..65298da 100644 --- a/src/app/api/pub/thumb-up-decrease/[publishId]/route.ts +++ b/src/app/api/pub/thumb-up-decrease/[publishId]/route.ts @@ -1,23 +1,33 @@ import { db } from '@/db/db' import { genSuccessData, genErrorData } from '@/app/api/utils/gen-res-data' +import { resolveViewerId } from '@/lib/viewer-id' -export async function PATCH(request: Request, { params }: { params: { publishId: string } }) { - const { publishId } = params // `publishId` is publish url suffix - return Response.json(genSuccessData()) +export async function PATCH(_request: Request, { params }: { params: { publishId: string } }) { + const { publishId } = params - // try { - // const p = await db.pubDoc.update({ - // where: { - // publishId, - // }, - // data: { - // thumbUpCount: { - // decrement: 1, // Decrement the thumb up count by 1 - // }, - // }, - // }) - // return Response.json(genSuccessData(p)) - // } catch (ex: any) { - // return Response.json(genErrorData(ex.message)) - // } + try { + const { viewerId } = await resolveViewerId() + const pubDoc = await db.pubDoc.findUnique({ where: { publishId }, select: { id: true, thumbUpCount: true } }) + if (!pubDoc) return Response.json(genErrorData('not found')) + + const result = await db.$transaction(async (tx) => { + const deleted = await tx.pubDocLike.deleteMany({ + where: { viewerId, pubDocId: pubDoc.id }, + }) + if (deleted.count === 0) { + const current = await tx.pubDoc.findUnique({ where: { publishId }, select: { thumbUpCount: true } }) + return { liked: false as const, count: current?.thumbUpCount ?? pubDoc.thumbUpCount } + } + await tx.pubDoc.updateMany({ + where: { publishId, thumbUpCount: { gt: 0 } }, + data: { thumbUpCount: { decrement: 1 } }, + }) + const current = await tx.pubDoc.findUnique({ where: { publishId }, select: { thumbUpCount: true } }) + return { liked: false as const, count: current?.thumbUpCount ?? 0 } + }) + + return Response.json(genSuccessData(result)) + } catch (ex: any) { + return Response.json(genErrorData(ex.message)) + } } diff --git a/src/app/api/pub/thumb-up/[publishId]/route.ts b/src/app/api/pub/thumb-up/[publishId]/route.ts index d3b4441..8ecefd5 100644 --- a/src/app/api/pub/thumb-up/[publishId]/route.ts +++ b/src/app/api/pub/thumb-up/[publishId]/route.ts @@ -1,21 +1,54 @@ import { db } from '@/db/db' import { genSuccessData, genErrorData } from '@/app/api/utils/gen-res-data' +import { resolveViewerId } from '@/lib/viewer-id' -export async function PATCH(request: Request, { params }: { params: { publishId: string } }) { - const { publishId } = params // `publishId` is publish url suffix +function isUniqueViolation(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && (error as { code: unknown }).code === 'P2002' +} + +export async function GET(_request: Request, { params }: { params: { publishId: string } }) { + const { publishId } = params try { - const p = await db.pubDoc.update({ - where: { - publishId, - }, - data: { - thumbUpCount: { - increment: 1, // Increment the thumb up count by 1 - }, - }, + const { viewerId } = await resolveViewerId() + const pubDoc = await db.pubDoc.findUnique({ where: { publishId }, select: { id: true, thumbUpCount: true } }) + if (!pubDoc) return Response.json(genErrorData('not found')) + + const like = await db.pubDocLike.findUnique({ + where: { viewerId_pubDocId: { viewerId, pubDocId: pubDoc.id } }, }) - return Response.json(genSuccessData(p)) + + return Response.json(genSuccessData({ liked: !!like, count: pubDoc.thumbUpCount })) + } catch (ex: any) { + return Response.json(genErrorData(ex.message)) + } +} + +export async function PATCH(_request: Request, { params }: { params: { publishId: string } }) { + const { publishId } = params + + try { + const { viewerId } = await resolveViewerId() + const pubDoc = await db.pubDoc.findUnique({ where: { publishId }, select: { id: true, thumbUpCount: true } }) + if (!pubDoc) return Response.json(genErrorData('not found')) + + try { + const updated = await db.$transaction(async (tx) => { + await tx.pubDocLike.create({ + data: { viewerId, pubDocId: pubDoc.id }, + }) + return tx.pubDoc.update({ + where: { publishId }, + data: { thumbUpCount: { increment: 1 } }, + select: { thumbUpCount: true }, + }) + }) + return Response.json(genSuccessData({ liked: true, count: updated.thumbUpCount })) + } catch (error) { + if (!isUniqueViolation(error)) throw error + const current = await db.pubDoc.findUnique({ where: { publishId }, select: { thumbUpCount: true } }) + return Response.json(genSuccessData({ liked: true, count: current?.thumbUpCount ?? pubDoc.thumbUpCount })) + } } catch (ex: any) { return Response.json(genErrorData(ex.message)) } diff --git a/src/components/thumb-up-button.tsx b/src/components/thumb-up-button.tsx index 937f229..f887d4a 100644 --- a/src/components/thumb-up-button.tsx +++ b/src/components/thumb-up-button.tsx @@ -1,53 +1,56 @@ 'use client' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect } from 'react' import { ThumbsUp } from 'lucide-react' import { Button } from '@/components/ui/button' -import { patch } from '@/lib/ajax' +import { get, patch } from '@/lib/ajax' export default function ThumbUpButton(props: { initialCount: number; publishId: string }) { - const { initialCount, publishId } = props // Default count if not provided - const STORE_KEY = useMemo(() => `thumbUp-${publishId}`, [publishId]) + const { initialCount, publishId } = props const [loading, setLoading] = useState(true) const [isLiked, setIsLiked] = useState(false) + const [thumbUpCount, setThumbUpCount] = useState(initialCount || 0) + useEffect(() => { - // Check if the user has already liked this publishId - const liked = localStorage.getItem(STORE_KEY) - if (liked) { - setIsLiked(true) - } - setLoading(false) - }, [STORE_KEY]) + get(`/api/pub/thumb-up/${publishId}`) + .then((res) => { + if (res.errno === 0 && res.data) { + setIsLiked(res.data.liked) + setThumbUpCount(res.data.count) + } + }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [publishId]) - const [thumbUpCount, setThumbUpCount] = useState(initialCount || 0) const handleThumbUp = async () => { - if (loading) return // Prevent multiple clicks + if (loading) return + const prevLiked = isLiked + const prevCount = thumbUpCount + if (isLiked) { - if (thumbUpCount <= 0) return // Prevent decrementing below zero - setThumbUpCount(thumbUpCount - 1) setIsLiked(false) - localStorage.removeItem(STORE_KEY) // Remove publishId from localStorage - await patchData(`/api/pub/thumb-up-decrease/${publishId}`) // Decrease thumb up count in the backend + setThumbUpCount(Math.max(0, thumbUpCount - 1)) } else { - setThumbUpCount(thumbUpCount + 1) setIsLiked(true) - localStorage.setItem(STORE_KEY, 'true') // store publishId in localStorage - await patchData(`/api/pub/thumb-up/${publishId}`) // Increase thumb up count in the backend + setThumbUpCount(thumbUpCount + 1) } - } - async function patchData(url: string) { setLoading(true) try { - const response = await patch(url, {}) - if (response.errno !== 0) { - throw new Error('Network response was not ok') + const url = isLiked ? `/api/pub/thumb-up-decrease/${publishId}` : `/api/pub/thumb-up/${publishId}` + const res = await patch(url, {}) + if (res.errno === 0 && res.data) { + setIsLiked(res.data.liked) + setThumbUpCount(res.data.count) + } else { + setIsLiked(prevLiked) + setThumbUpCount(prevCount) } - const data = response.data - return data - } catch (error) { - console.error('Error:', error) + } catch { + setIsLiked(prevLiked) + setThumbUpCount(prevCount) } finally { setLoading(false) } diff --git a/src/lib/viewer-id.ts b/src/lib/viewer-id.ts new file mode 100644 index 0000000..e235364 --- /dev/null +++ b/src/lib/viewer-id.ts @@ -0,0 +1,35 @@ +import 'server-only' +import { cookies } from 'next/headers' +import { auth } from 'auth' + +const COOKIE_NAME = 'viewer_id' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 + +/** + * Viewer identity for published-document likes (#72). + * Signed-in users are `user:{id}`. Anonymous visitors get an httpOnly UUID + * cookie: it is a documented opaque token, not a signed capability, and it + * only scopes like rows for this browser profile. + */ +export async function resolveViewerId(): Promise<{ viewerId: string; setCookie: boolean }> { + const session = await auth() + if (session?.user?.id) { + return { viewerId: `user:${session.user.id}`, setCookie: false } + } + + const cookieStore = await cookies() + const existing = cookieStore.get(COOKIE_NAME) + if (existing?.value) { + return { viewerId: existing.value, setCookie: false } + } + + const viewerId = crypto.randomUUID() + cookieStore.set(COOKIE_NAME, viewerId, { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: COOKIE_MAX_AGE, + path: '/', + }) + return { viewerId, setCookie: true } +}