Skip to content

Commit ff7e75e

Browse files
authored
Merge pull request #166 from guidefari/migration/6-favorites-group
feat(vps): port favorites to HttpApiBuilder.group (Step 6)
2 parents 95726e6 + 1f05dda commit ff7e75e

11 files changed

Lines changed: 255 additions & 222 deletions

File tree

apps/vps/src/app.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import configureOpenAPI from '@/lib/configure-open-api'
33
import { createAppEffect } from '@/lib/create-app'
44
import content from '@/routes/content/content.index'
55
import email from '@/routes/email/email.index'
6-
import favorites from '@/routes/favorites/favorites.index'
76
import fileManager from '@/routes/file-manager/file-manager.index'
87
import musicReminders from '@/routes/music-reminders/music-reminders.index'
98
import newsletter from '@/routes/newsletter/newsletter.index'
@@ -26,7 +25,6 @@ const setupRoutesEffect = Effect.gen(function* () {
2625

2726
configureOpenAPI(app)
2827

29-
app.route('/api/favorites', favorites)
3028
app.route('/api/user', user)
3129
app.route('/api/content', content)
3230
app.route('/api/email', email)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { Api } from '@gbfm/api/api'
2+
import { AuthSession } from '@gbfm/api/middleware/auth'
3+
import { Effect } from 'effect'
4+
import { HttpApiBuilder, HttpApiError } from 'effect/unstable/httpapi'
5+
import { dieOnDatabaseError as makeDieOnDatabaseError } from '@/http/handler-utils'
6+
import { FavoriteService } from '@/services/favorite.service'
7+
8+
const dieOnDatabaseError = makeDieOnDatabaseError('favorites')
9+
10+
const toFavoriteResponse = (favorite: {
11+
id: string
12+
userId: string
13+
audioId: string | null
14+
showId: string | null
15+
createdAt: Date
16+
audio: {
17+
id: string
18+
title: string
19+
slug: string
20+
thumbnailUrl: string | null
21+
type: 'mix' | 'track' | 'misc'
22+
url: string
23+
} | null
24+
show: { id: string; title: string; slug: string; thumbnailUrl: string | null } | null
25+
}) => ({
26+
...favorite,
27+
createdAt: favorite.createdAt.toISOString()
28+
})
29+
30+
export const FavoritesHandlersLive = HttpApiBuilder.group(Api, 'favorites', (handlers) =>
31+
handlers
32+
.handle('addFavorite', ({ payload }) =>
33+
Effect.gen(function* () {
34+
const { user } = yield* AuthSession
35+
const svc = yield* FavoriteService
36+
37+
if (payload.audioId) {
38+
yield* dieOnDatabaseError(
39+
svc.addFavorite(user.id, payload.audioId).pipe(
40+
Effect.catchTag('NotFoundError', () => new HttpApiError.NotFound()),
41+
Effect.catchTag('ConflictError', () => new HttpApiError.Conflict())
42+
)
43+
)
44+
} else if (payload.showId) {
45+
yield* dieOnDatabaseError(
46+
svc.addShowFavorite(user.id, payload.showId).pipe(
47+
Effect.catchTag('NotFoundError', () => new HttpApiError.NotFound()),
48+
Effect.catchTag('ConflictError', () => new HttpApiError.Conflict())
49+
)
50+
)
51+
} else {
52+
return yield* new HttpApiError.BadRequest()
53+
}
54+
55+
return { success: true, message: 'Added to favorites' }
56+
})
57+
)
58+
.handle('removeFavorite', ({ params }) =>
59+
Effect.gen(function* () {
60+
const { user } = yield* AuthSession
61+
const svc = yield* FavoriteService
62+
63+
yield* dieOnDatabaseError(
64+
svc
65+
.removeFavorite(user.id, params.audioId)
66+
.pipe(Effect.catchTag('NotFoundError', () => new HttpApiError.NotFound()))
67+
)
68+
69+
return { success: true, message: 'Removed from favorites' }
70+
})
71+
)
72+
.handle('removeShowFavorite', ({ params }) =>
73+
Effect.gen(function* () {
74+
const { user } = yield* AuthSession
75+
const svc = yield* FavoriteService
76+
77+
yield* dieOnDatabaseError(
78+
svc
79+
.removeShowFavorite(user.id, params.showId)
80+
.pipe(Effect.catchTag('NotFoundError', () => new HttpApiError.NotFound()))
81+
)
82+
83+
return { success: true, message: 'Removed from favorites' }
84+
})
85+
)
86+
.handle('getFavorites', ({ query }) =>
87+
Effect.gen(function* () {
88+
const { user } = yield* AuthSession
89+
const svc = yield* FavoriteService
90+
91+
const favorites = yield* dieOnDatabaseError(
92+
svc.getFavorites(user.id, query.limit, query.offset)
93+
)
94+
95+
return {
96+
success: true,
97+
favorites: favorites.map(toFavoriteResponse),
98+
total: favorites.length
99+
}
100+
})
101+
)
102+
)

apps/vps/src/http/routes.blackbox.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,54 @@ describe('invite (HttpApiBuilder group, Step 6)', () => {
325325
expect(res.status).toBe(400)
326326
})
327327
})
328+
329+
describe('favorites (HttpApiBuilder group, Step 6)', () => {
330+
it('GET /api/favorites returns 401 without a session cookie', async () => {
331+
const res = await webHandler.handler(new Request('http://localhost/api/favorites'))
332+
333+
expect(res.status).toBe(401)
334+
})
335+
336+
it('POST /api/favorites returns 401 without a session cookie', async () => {
337+
const res = await webHandler.handler(
338+
new Request('http://localhost/api/favorites', {
339+
method: 'POST',
340+
headers: { 'content-type': 'application/json' },
341+
body: JSON.stringify({ audioId: '00000000-0000-0000-0000-000000000000' })
342+
})
343+
)
344+
345+
expect(res.status).toBe(401)
346+
})
347+
348+
it('DELETE /api/favorites/:audioId returns 401 without a session cookie', async () => {
349+
const res = await webHandler.handler(
350+
new Request('http://localhost/api/favorites/00000000-0000-0000-0000-000000000000', {
351+
method: 'DELETE'
352+
})
353+
)
354+
355+
expect(res.status).toBe(401)
356+
})
357+
358+
it('DELETE /api/favorites/show/:showId returns 401 without a session cookie', async () => {
359+
const res = await webHandler.handler(
360+
new Request('http://localhost/api/favorites/show/00000000-0000-0000-0000-000000000000', {
361+
method: 'DELETE'
362+
})
363+
)
364+
365+
expect(res.status).toBe(401)
366+
})
367+
368+
it('DELETE /api/favorites/:audioId returns 401 (not 400) for a non-UUID audioId without a session cookie', async () => {
369+
const res = await webHandler.handler(
370+
new Request('http://localhost/api/favorites/not-a-uuid', { method: 'DELETE' })
371+
)
372+
373+
// AuthMiddleware runs before param schema validation (same ordering as
374+
// admin's frontend-errors :scenario case) -- a malformed audioId still
375+
// 401s rather than 400ing, since there's no session cookie either way.
376+
expect(res.status).toBe(401)
377+
})
378+
})

apps/vps/src/http/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from 'e
55
import { HttpApiBuilder } from 'effect/unstable/httpapi'
66
import type { AppType } from '@/app'
77
import { AdminHandlersLive } from '@/http/admin.handlers'
8+
import { FavoritesHandlersLive } from '@/http/favorites.handlers'
89
import { checkDatabase, makeHealthHandlers } from '@/http/health.handlers'
910
import { InviteHandlersLive } from '@/http/invite.handlers'
1011
import { InternalHandlersLive } from '@/http/internal.handlers'
@@ -64,6 +65,7 @@ export const createWebHandler = (
6465
Layer.provide(ResolveHandlersLive),
6566
Layer.provide(AdminHandlersLive),
6667
Layer.provide(InviteHandlersLive),
68+
Layer.provide(FavoritesHandlersLive),
6769
Layer.provide(AuthMiddlewareLive),
6870
// provideMerge, not provide: services a handler pulls via plain `yield*`
6971
// only clear toWebHandler's phantom-context requirement once they're

apps/vps/src/routes/favorites/favorites.handlers.ts

Lines changed: 0 additions & 91 deletions
This file was deleted.

apps/vps/src/routes/favorites/favorites.index.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

0 commit comments

Comments
 (0)