Skip to content

Commit defff53

Browse files
authored
feat: add post likes counter with IP rate limiting and localStorage d… (#117)
* feat: add post likes counter with IP rate limiting and localStorage dedup Adds a heart like button per post with optimistic UI, server-side IP rate limiting via @upstash/ratelimit (1 like/IP/24h), and localStorage for client-side dedup. Moves like + subscribe actions to the post hero meta row opposite the share icons. Requires DB migration: * fix: prefixed modal keys to prevent collision, add missing coverage tests
1 parent b428e20 commit defff53

26 files changed

Lines changed: 722 additions & 73 deletions

File tree

apps/web/app/[lng]/blog/[id]/[slug]/page.next.styles.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,3 @@ export const StyledPage = styled.main`
1010
padding: 0 2rem 5rem;
1111
}
1212
`
13-
14-
export const StyledSubscribeWrapper = styled.div`
15-
margin-top: 3rem;
16-
`

apps/web/app/[lng]/blog/[id]/[slug]/page.next.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { TableOfContents } from '@sdlgr/table-of-contents'
77

88
import { JsonLd } from '@web/components/JsonLd/JsonLd'
99
import { PostContent } from '@web/components/PostContent/PostContent'
10+
import { PostLikeButton } from '@web/components/PostLikeButton'
1011
import { RelatedPosts } from '@web/components/RelatedPosts/RelatedPosts'
1112
import { SeriesIndicator } from '@web/components/SeriesIndicator/SeriesIndicator'
1213
import { SubscribeModal } from '@web/components/SubscribeModal'
@@ -27,7 +28,7 @@ import {
2728
} from '@web/utils/metadata/inLanguage'
2829
import { getSiteUrl } from '@web/utils/url/generateUrl'
2930

30-
import { StyledPage, StyledSubscribeWrapper } from './page.next.styles'
31+
import { StyledPage } from './page.next.styles'
3132

3233
export const revalidate = 3600
3334

@@ -177,6 +178,17 @@ export default async function BlogPostPage({ params }: RouteProps) {
177178
copyLinkLabel={t('share.copyLink')}
178179
copiedLabel={t('share.copied')}
179180
categoryIcon={<CategoryIconRenderer slug={post.category} aria-hidden />}
181+
actions={
182+
<>
183+
<PostLikeButton postId={post.id} initialLikes={post.likes} />
184+
<SubscribeModal
185+
lng={lng}
186+
buttonLabel={tSubscribe('buttonLabel')}
187+
buttonAriaLabel={tSubscribe('buttonAriaLabel')}
188+
compact
189+
/>
190+
</>
191+
}
180192
/>
181193
{toc.length > 0 && <TableOfContents entries={toc} label={t('toc')} />}
182194
<PostContent>
@@ -194,13 +206,6 @@ export default async function BlogPostPage({ params }: RouteProps) {
194206
/>
195207
)}
196208
<RelatedPosts postId={post.id} lng={lng} />
197-
<StyledSubscribeWrapper>
198-
<SubscribeModal
199-
lng={lng}
200-
buttonLabel={tSubscribe('buttonLabel')}
201-
buttonAriaLabel={tSubscribe('buttonAriaLabel')}
202-
/>
203-
</StyledSubscribeWrapper>
204209
</StyledPage>
205210
)
206211
}

apps/web/app/[lng]/blog/[id]/[slug]/page.spec.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ jest.mock('@web/components/PostContent/PostContent', () => ({
101101
),
102102
}))
103103

104+
jest.mock('@web/components/PostLikeButton', () => ({
105+
PostLikeButton: () => <div data-testid="post-like-button" />,
106+
}))
107+
104108
const publishedPost = {
105109
id: '01JXYZ',
106110
postNumber: 1,
@@ -113,6 +117,7 @@ const publishedPost = {
113117
category: 'Tech',
114118
tags: ['next.js', 'react'],
115119
author: 'Jane Doe',
120+
likes: 0,
116121
publishedAt: new Date('2024-01-01T00:00:00.000Z'),
117122
updatedAt: new Date('2024-06-15T00:00:00.000Z'),
118123
content: 'Hello world content',

apps/web/app/admin/(auth)/posts/components/PostEditor/PostEditor.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,8 @@ export function PostEditor({
247247
const [isEmbedInsertModalOpen, setIsEmbedInsertModalOpen] = useState(false)
248248
const [isGpxModalOpen, setIsGpxModalOpen] = useState(false)
249249
const [imageModalKey, setImageModalKey] = useState(0)
250-
const [embedModalKey, setEmbedModalKey] = useState(1)
251-
const [gpxModalKey, setGpxModalKey] = useState(2)
250+
const [embedModalKey, setEmbedModalKey] = useState(0)
251+
const [gpxModalKey, setGpxModalKey] = useState(0)
252252
const [showPublishNotify, setShowPublishNotify] = useState(false)
253253
const [editingEmbed, setEditingEmbed] = useState<DetectedEmbed | null>(null)
254254
const [imageInitialValues, setImageInitialValues] =
@@ -966,7 +966,7 @@ export function PostEditor({
966966
zIndex={1100}
967967
/>
968968
<ImageInsertModal
969-
key={imageModalKey}
969+
key={`image-${imageModalKey}`}
970970
isOpen={isImageInsertModalOpen}
971971
initialValues={imageInitialValues}
972972
onInsert={(markdown) => {
@@ -993,7 +993,7 @@ export function PostEditor({
993993
}}
994994
/>
995995
<EmbedInsertModal
996-
key={embedModalKey}
996+
key={`embed-${embedModalKey}`}
997997
isOpen={isEmbedInsertModalOpen}
998998
initialValues={embedInitialValues}
999999
onInsert={(markdown) => {
@@ -1015,7 +1015,7 @@ export function PostEditor({
10151015
}}
10161016
/>
10171017
<GpxMapModal
1018-
key={gpxModalKey}
1018+
key={`gpx-${gpxModalKey}`}
10191019
isOpen={isGpxModalOpen}
10201020
initialValues={gpxInitialValues}
10211021
onInsert={(markdown) => {
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
const mockGetPostStatus = jest.fn()
5+
const mockIncrementPostLikes = jest.fn()
6+
const mockLikesRatelimit = { limit: jest.fn() }
7+
const mockLoggerError = jest.fn()
8+
9+
jest.mock('@web/lib/db/queries/posts', () => ({
10+
getPostStatus: (...args: unknown[]) => mockGetPostStatus(...args),
11+
incrementPostLikes: (...args: unknown[]) => mockIncrementPostLikes(...args),
12+
}))
13+
jest.mock('@web/lib/ratelimit', () => ({
14+
get likesRatelimit() {
15+
return mockLikesRatelimit
16+
},
17+
}))
18+
jest.mock('@web/lib/logger', () => ({
19+
logger: {
20+
error: mockLoggerError,
21+
info: jest.fn(),
22+
debug: jest.fn(),
23+
warn: jest.fn(),
24+
},
25+
}))
26+
27+
const { POST } = require('./route') as {
28+
POST: (
29+
req: Request,
30+
ctx: { params: Promise<{ id: string }> },
31+
) => Promise<Response>
32+
}
33+
34+
function makeRequest(headers: Record<string, string> = {}) {
35+
return new Request('http://localhost/api/posts/some-id/like', {
36+
method: 'POST',
37+
headers,
38+
})
39+
}
40+
41+
function makeParams(id = 'post-ulid-123') {
42+
return { params: Promise.resolve({ id }) }
43+
}
44+
45+
beforeEach(() => {
46+
jest.clearAllMocks()
47+
mockLikesRatelimit.limit.mockResolvedValue({ success: true })
48+
mockGetPostStatus.mockResolvedValue('published')
49+
mockIncrementPostLikes.mockResolvedValue(42)
50+
})
51+
52+
describe('POST /api/posts/[id]/like', () => {
53+
it('increments likes and returns count', async () => {
54+
const res = await POST(makeRequest(), makeParams())
55+
expect(res.status).toBe(200)
56+
expect(await res.json()).toEqual({ likes: 42 })
57+
expect(mockIncrementPostLikes).toHaveBeenCalledWith('post-ulid-123')
58+
})
59+
60+
it('returns 429 when rate limit exceeded', async () => {
61+
mockLikesRatelimit.limit.mockResolvedValue({ success: false })
62+
const res = await POST(makeRequest(), makeParams())
63+
expect(res.status).toBe(429)
64+
expect(await res.json()).toEqual({ error: 'Already liked' })
65+
expect(mockIncrementPostLikes).not.toHaveBeenCalled()
66+
})
67+
68+
it('uses x-forwarded-for ip for rate limit key', async () => {
69+
const res = await POST(
70+
makeRequest({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' }),
71+
makeParams(),
72+
)
73+
expect(res.status).toBe(200)
74+
expect(mockLikesRatelimit.limit).toHaveBeenCalledWith(
75+
'like:post-ulid-123:1.2.3.4',
76+
)
77+
})
78+
79+
it('uses x-real-ip when x-forwarded-for absent', async () => {
80+
await POST(makeRequest({ 'x-real-ip': '9.9.9.9' }), makeParams())
81+
expect(mockLikesRatelimit.limit).toHaveBeenCalledWith(
82+
'like:post-ulid-123:9.9.9.9',
83+
)
84+
})
85+
86+
it('falls back to anonymous when no ip headers', async () => {
87+
await POST(makeRequest(), makeParams())
88+
expect(mockLikesRatelimit.limit).toHaveBeenCalledWith(
89+
'like:post-ulid-123:anonymous',
90+
)
91+
})
92+
93+
it('returns 404 when post not found', async () => {
94+
mockGetPostStatus.mockResolvedValue(null)
95+
const res = await POST(makeRequest(), makeParams())
96+
expect(res.status).toBe(404)
97+
expect(mockIncrementPostLikes).not.toHaveBeenCalled()
98+
})
99+
100+
it('returns 404 when post is not published', async () => {
101+
mockGetPostStatus.mockResolvedValue('draft')
102+
const res = await POST(makeRequest(), makeParams())
103+
expect(res.status).toBe(404)
104+
expect(mockIncrementPostLikes).not.toHaveBeenCalled()
105+
})
106+
107+
it('returns 500 when increment throws', async () => {
108+
mockIncrementPostLikes.mockRejectedValue(new Error('db error'))
109+
const res = await POST(makeRequest(), makeParams())
110+
expect(res.status).toBe(500)
111+
expect(mockLoggerError).toHaveBeenCalled()
112+
})
113+
114+
it('skips rate limit when likesRatelimit is null', async () => {
115+
jest.resetModules()
116+
jest.mock('@web/lib/ratelimit', () => ({ likesRatelimit: null }))
117+
jest.mock('@web/lib/db/queries/posts', () => ({
118+
getPostStatus: () => Promise.resolve('published'),
119+
incrementPostLikes: () => Promise.resolve(1),
120+
}))
121+
jest.mock('@web/lib/logger', () => ({
122+
logger: {
123+
error: jest.fn(),
124+
info: jest.fn(),
125+
debug: jest.fn(),
126+
warn: jest.fn(),
127+
},
128+
}))
129+
const { POST: POST2 } = require('./route') as typeof import('./route')
130+
const res = await POST2(makeRequest(), makeParams())
131+
expect(res.status).toBe(200)
132+
})
133+
})
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
import { getPostStatus, incrementPostLikes } from '@web/lib/db/queries/posts'
4+
import { logger } from '@web/lib/logger'
5+
import { likesRatelimit } from '@web/lib/ratelimit'
6+
7+
export async function POST(
8+
request: NextRequest,
9+
{ params }: { params: Promise<{ id: string }> },
10+
) {
11+
const { id } = await params
12+
13+
const ip =
14+
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
15+
request.headers.get('x-real-ip') ??
16+
'anonymous'
17+
18+
if (likesRatelimit) {
19+
const { success } = await likesRatelimit.limit(`like:${id}:${ip}`)
20+
if (!success) {
21+
return NextResponse.json({ error: 'Already liked' }, { status: 429 })
22+
}
23+
}
24+
25+
const status = await getPostStatus(id)
26+
if (!status || status !== 'published') {
27+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
28+
}
29+
30+
try {
31+
const likes = await incrementPostLikes(id)
32+
return NextResponse.json({ likes })
33+
} catch (error) {
34+
logger.error(error, 'Failed to increment likes')
35+
return NextResponse.json(
36+
{ error: 'Internal server error' },
37+
{ status: 500 },
38+
)
39+
}
40+
}

0 commit comments

Comments
 (0)