Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
18 changes: 18 additions & 0 deletions prisma/migrations/20260918000000_add_pub_doc_like/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
134 changes: 134 additions & 0 deletions src/__tests__/api/pub-like-viewer.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
46 changes: 28 additions & 18 deletions src/app/api/pub/thumb-up-decrease/[publishId]/route.ts
Original file line number Diff line number Diff line change
@@ -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))
}
}
57 changes: 45 additions & 12 deletions src/app/api/pub/thumb-up/[publishId]/route.ts
Original file line number Diff line number Diff line change
@@ -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))
}
Expand Down
61 changes: 32 additions & 29 deletions src/components/thumb-up-button.tsx
Original file line number Diff line number Diff line change
@@ -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)
}
Expand Down
Loading
Loading