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
2 changes: 0 additions & 2 deletions apps/vps/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import configureOpenAPI from '@/lib/configure-open-api'
import { createAppEffect } from '@/lib/create-app'
import content from '@/routes/content/content.index'
import email from '@/routes/email/email.index'
import favorites from '@/routes/favorites/favorites.index'
import fileManager from '@/routes/file-manager/file-manager.index'
import musicReminders from '@/routes/music-reminders/music-reminders.index'
import newsletter from '@/routes/newsletter/newsletter.index'
Expand All @@ -26,7 +25,6 @@ const setupRoutesEffect = Effect.gen(function* () {

configureOpenAPI(app)

app.route('/api/favorites', favorites)
app.route('/api/user', user)
app.route('/api/content', content)
app.route('/api/email', email)
Expand Down
102 changes: 102 additions & 0 deletions apps/vps/src/http/favorites.handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Api } from '@gbfm/api/api'
import { AuthSession } from '@gbfm/api/middleware/auth'
import { Effect } from 'effect'
import { HttpApiBuilder, HttpApiError } from 'effect/unstable/httpapi'
import { dieOnDatabaseError as makeDieOnDatabaseError } from '@/http/handler-utils'
import { FavoriteService } from '@/services/favorite.service'

const dieOnDatabaseError = makeDieOnDatabaseError('favorites')

const toFavoriteResponse = (favorite: {
id: string
userId: string
audioId: string | null
showId: string | null
createdAt: Date
audio: {
id: string
title: string
slug: string
thumbnailUrl: string | null
type: 'mix' | 'track' | 'misc'
url: string
} | null
show: { id: string; title: string; slug: string; thumbnailUrl: string | null } | null
}) => ({
...favorite,
createdAt: favorite.createdAt.toISOString()
})

export const FavoritesHandlersLive = HttpApiBuilder.group(Api, 'favorites', (handlers) =>
handlers
.handle('addFavorite', ({ payload }) =>
Effect.gen(function* () {
const { user } = yield* AuthSession
const svc = yield* FavoriteService

if (payload.audioId) {
yield* dieOnDatabaseError(
svc.addFavorite(user.id, payload.audioId).pipe(
Effect.catchTag('NotFoundError', () => new HttpApiError.NotFound()),
Effect.catchTag('ConflictError', () => new HttpApiError.Conflict())
)
)
} else if (payload.showId) {
yield* dieOnDatabaseError(
svc.addShowFavorite(user.id, payload.showId).pipe(
Effect.catchTag('NotFoundError', () => new HttpApiError.NotFound()),
Effect.catchTag('ConflictError', () => new HttpApiError.Conflict())
)
)
} else {
return yield* new HttpApiError.BadRequest()
}

return { success: true, message: 'Added to favorites' }
})
)
.handle('removeFavorite', ({ params }) =>
Effect.gen(function* () {
const { user } = yield* AuthSession
const svc = yield* FavoriteService

yield* dieOnDatabaseError(
svc
.removeFavorite(user.id, params.audioId)
.pipe(Effect.catchTag('NotFoundError', () => new HttpApiError.NotFound()))
)

return { success: true, message: 'Removed from favorites' }
})
)
.handle('removeShowFavorite', ({ params }) =>
Effect.gen(function* () {
const { user } = yield* AuthSession
const svc = yield* FavoriteService

yield* dieOnDatabaseError(
svc
.removeShowFavorite(user.id, params.showId)
.pipe(Effect.catchTag('NotFoundError', () => new HttpApiError.NotFound()))
)

return { success: true, message: 'Removed from favorites' }
})
)
.handle('getFavorites', ({ query }) =>
Effect.gen(function* () {
const { user } = yield* AuthSession
const svc = yield* FavoriteService

const favorites = yield* dieOnDatabaseError(
svc.getFavorites(user.id, query.limit, query.offset)
)

return {
success: true,
favorites: favorites.map(toFavoriteResponse),
total: favorites.length
}
})
)
)
51 changes: 51 additions & 0 deletions apps/vps/src/http/routes.blackbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,3 +325,54 @@ describe('invite (HttpApiBuilder group, Step 6)', () => {
expect(res.status).toBe(400)
})
})

describe('favorites (HttpApiBuilder group, Step 6)', () => {
it('GET /api/favorites returns 401 without a session cookie', async () => {
const res = await webHandler.handler(new Request('http://localhost/api/favorites'))

expect(res.status).toBe(401)
})

it('POST /api/favorites returns 401 without a session cookie', async () => {
const res = await webHandler.handler(
new Request('http://localhost/api/favorites', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ audioId: '00000000-0000-0000-0000-000000000000' })
})
)

expect(res.status).toBe(401)
})

it('DELETE /api/favorites/:audioId returns 401 without a session cookie', async () => {
const res = await webHandler.handler(
new Request('http://localhost/api/favorites/00000000-0000-0000-0000-000000000000', {
method: 'DELETE'
})
)

expect(res.status).toBe(401)
})

it('DELETE /api/favorites/show/:showId returns 401 without a session cookie', async () => {
const res = await webHandler.handler(
new Request('http://localhost/api/favorites/show/00000000-0000-0000-0000-000000000000', {
method: 'DELETE'
})
)

expect(res.status).toBe(401)
})

it('DELETE /api/favorites/:audioId returns 401 (not 400) for a non-UUID audioId without a session cookie', async () => {
const res = await webHandler.handler(
new Request('http://localhost/api/favorites/not-a-uuid', { method: 'DELETE' })
)

// AuthMiddleware runs before param schema validation (same ordering as
// admin's frontend-errors :scenario case) -- a malformed audioId still
// 401s rather than 400ing, since there's no session cookie either way.
expect(res.status).toBe(401)
})
})
2 changes: 2 additions & 0 deletions apps/vps/src/http/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from 'e
import { HttpApiBuilder } from 'effect/unstable/httpapi'
import type { AppType } from '@/app'
import { AdminHandlersLive } from '@/http/admin.handlers'
import { FavoritesHandlersLive } from '@/http/favorites.handlers'
import { checkDatabase, makeHealthHandlers } from '@/http/health.handlers'
import { InviteHandlersLive } from '@/http/invite.handlers'
import { InternalHandlersLive } from '@/http/internal.handlers'
Expand Down Expand Up @@ -64,6 +65,7 @@ export const createWebHandler = (
Layer.provide(ResolveHandlersLive),
Layer.provide(AdminHandlersLive),
Layer.provide(InviteHandlersLive),
Layer.provide(FavoritesHandlersLive),
Layer.provide(AuthMiddlewareLive),
// provideMerge, not provide: services a handler pulls via plain `yield*`
// only clear toWebHandler's phantom-context requirement once they're
Expand Down
91 changes: 0 additions & 91 deletions apps/vps/src/routes/favorites/favorites.handlers.ts

This file was deleted.

11 changes: 0 additions & 11 deletions apps/vps/src/routes/favorites/favorites.index.ts

This file was deleted.

Loading