diff --git a/apps/vps/src/app.ts b/apps/vps/src/app.ts index e25e178f..10eddc77 100644 --- a/apps/vps/src/app.ts +++ b/apps/vps/src/app.ts @@ -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' @@ -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) diff --git a/apps/vps/src/http/favorites.handlers.ts b/apps/vps/src/http/favorites.handlers.ts new file mode 100644 index 00000000..b0ba2789 --- /dev/null +++ b/apps/vps/src/http/favorites.handlers.ts @@ -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 + } + }) + ) +) diff --git a/apps/vps/src/http/routes.blackbox.test.ts b/apps/vps/src/http/routes.blackbox.test.ts index f5d874c8..0e6c4885 100644 --- a/apps/vps/src/http/routes.blackbox.test.ts +++ b/apps/vps/src/http/routes.blackbox.test.ts @@ -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) + }) +}) diff --git a/apps/vps/src/http/routes.ts b/apps/vps/src/http/routes.ts index 9de22eb9..02e7d6e3 100644 --- a/apps/vps/src/http/routes.ts +++ b/apps/vps/src/http/routes.ts @@ -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' @@ -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 diff --git a/apps/vps/src/routes/favorites/favorites.handlers.ts b/apps/vps/src/routes/favorites/favorites.handlers.ts deleted file mode 100644 index 849b3ef3..00000000 --- a/apps/vps/src/routes/favorites/favorites.handlers.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Effect } from 'effect' -import * as HttpStatusCodes from 'stoker/http-status-codes' -import { runEffect } from '@/lib/effect-hono' -import type { AppRouteHandler } from '@/lib/types' -import { runAppFork } from '@/runtime' -import { FavoriteService } from '@/services/favorite.service' - -import type { - AddFavoriteRoute, - GetFavoritesRoute, - RemoveFavoriteRoute, - RemoveShowFavoriteRoute -} from './favorites.routes' - -export const addFavorite: AppRouteHandler = async (c) => { - const user = c.get('user') - const { audioId, showId } = c.req.valid('json') - - Effect.annotateCurrentSpan('userId', user.id).pipe(runAppFork) - Effect.annotateCurrentSpan('operation', 'add-favorite').pipe(runAppFork) - Effect.logInfo('[API] Add favorite requested', { - userId: user.id, - audioId, - showId - }).pipe(runAppFork) - - const program = Effect.gen(function* () { - const svc = yield* FavoriteService - if (audioId) yield* svc.addFavorite(user.id, audioId) - else if (showId) yield* svc.addShowFavorite(user.id, showId) - return { success: true, message: 'Added to favorites' } as const - }) - - return runEffect(c, program, HttpStatusCodes.CREATED) -} - -export const removeFavorite: AppRouteHandler = async (c) => { - const user = c.get('user') - const { audioId } = c.req.valid('param') - - Effect.logInfo('[API] Remove favorite requested', { - userId: user.id, - audioId - }).pipe(runAppFork) - - const program = Effect.gen(function* () { - const svc = yield* FavoriteService - yield* svc.removeFavorite(user.id, audioId) - return { success: true, message: 'Removed from favorites' } as const - }) - - return runEffect(c, program) -} - -export const removeShowFavorite: AppRouteHandler = async (c) => { - const user = c.get('user') - const { showId } = c.req.valid('param') - - Effect.logInfo('[API] Remove show favorite requested', { - userId: user.id, - showId - }).pipe(runAppFork) - - const program = Effect.gen(function* () { - const svc = yield* FavoriteService - yield* svc.removeShowFavorite(user.id, showId) - return { success: true, message: 'Removed from favorites' } as const - }) - - return runEffect(c, program) -} - -export const getFavorites: AppRouteHandler = async (c) => { - const user = c.get('user') - const { limit, offset } = c.req.valid('query') - - const program = Effect.gen(function* () { - const svc = yield* FavoriteService - const favorites = yield* svc.getFavorites(user.id, limit, offset) - return { - success: true, - favorites: favorites.map((fav) => ({ - ...fav, - createdAt: fav.createdAt.toISOString() - })), - total: favorites.length - } as const - }) - - return runEffect(c, program) -} diff --git a/apps/vps/src/routes/favorites/favorites.index.ts b/apps/vps/src/routes/favorites/favorites.index.ts deleted file mode 100644 index 1439181c..00000000 --- a/apps/vps/src/routes/favorites/favorites.index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createRouter } from '@/lib/create-app' -import * as handlers from './favorites.handlers' -import * as routes from './favorites.routes' - -const router = createRouter() - .openapi(routes.addFavorite, handlers.addFavorite) - .openapi(routes.removeShowFavorite, handlers.removeShowFavorite) - .openapi(routes.removeFavorite, handlers.removeFavorite) - .openapi(routes.getFavorites, handlers.getFavorites) - -export default router diff --git a/apps/vps/src/routes/favorites/favorites.routes.ts b/apps/vps/src/routes/favorites/favorites.routes.ts deleted file mode 100644 index 69452be0..00000000 --- a/apps/vps/src/routes/favorites/favorites.routes.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { createRoute, z } from '@hono/zod-openapi' -import * as HttpStatusCodes from 'stoker/http-status-codes' -import { jsonContent, jsonContentRequired } from 'stoker/openapi/helpers' -import { favoriteWithAudioSchema, insertFavoriteSchema } from '@/db/favorites.schema' -import { betterAuthMiddleware } from '@/middlewares/better-auth.middleware' - -const tags = ['Favorites'] - -export const getFavoritesResponseSchema = z.union([ - z.object({ - success: z.boolean(), - favorites: z.array(favoriteWithAudioSchema), - total: z.number() - }), - z.object({ - error: z.string() - }) -]) - -export const favoriteResponseSchema = z.union([ - z.object({ - success: z.boolean(), - message: z.string() - }), - z.object({ - error: z.string() - }) -]) - -export const addFavorite = createRoute({ - path: '/', - method: 'post', - middleware: [betterAuthMiddleware], - request: { - body: jsonContentRequired(insertFavoriteSchema, 'Audio to favorite') - }, - responses: { - [HttpStatusCodes.CREATED]: jsonContent(favoriteResponseSchema, 'Favorite added successfully'), - [HttpStatusCodes.UNAUTHORIZED]: jsonContent(favoriteResponseSchema, 'Authentication required'), - [HttpStatusCodes.CONFLICT]: jsonContent(favoriteResponseSchema, 'Already favorited'), - [HttpStatusCodes.NOT_FOUND]: jsonContent(favoriteResponseSchema, 'Audio not found'), - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( - favoriteResponseSchema, - 'Internal server error' - ) - }, - tags -}) - -export const removeFavorite = createRoute({ - path: '/:audioId', - method: 'delete', - middleware: [betterAuthMiddleware], - request: { - params: z.object({ audioId: z.string().uuid() }) - }, - responses: { - [HttpStatusCodes.OK]: jsonContent(favoriteResponseSchema, 'Favorite removed successfully'), - [HttpStatusCodes.NOT_FOUND]: jsonContent(favoriteResponseSchema, 'Favorite not found'), - [HttpStatusCodes.UNAUTHORIZED]: jsonContent(favoriteResponseSchema, 'Authentication required'), - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( - favoriteResponseSchema, - 'Internal server error' - ) - }, - tags -}) - -export const getFavorites = createRoute({ - path: '/', - method: 'get', - middleware: [betterAuthMiddleware], - request: { - query: z.object({ - limit: z.coerce.number().min(1).max(100).optional().default(20), - offset: z.coerce.number().min(0).optional().default(0) - }) - }, - responses: { - [HttpStatusCodes.OK]: jsonContent( - getFavoritesResponseSchema, - 'Favorites retrieved successfully' - ), - [HttpStatusCodes.UNAUTHORIZED]: jsonContent( - getFavoritesResponseSchema, - 'Authentication required' - ), - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( - getFavoritesResponseSchema, - 'Internal server error' - ) - }, - tags -}) - -export const removeShowFavorite = createRoute({ - path: '/show/:showId', - method: 'delete', - middleware: [betterAuthMiddleware], - request: { - params: z.object({ showId: z.string().uuid() }) - }, - responses: { - [HttpStatusCodes.OK]: jsonContent(favoriteResponseSchema, 'Show favorite removed successfully'), - [HttpStatusCodes.NOT_FOUND]: jsonContent(favoriteResponseSchema, 'Favorite not found'), - [HttpStatusCodes.UNAUTHORIZED]: jsonContent(favoriteResponseSchema, 'Authentication required'), - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( - favoriteResponseSchema, - 'Internal server error' - ) - }, - tags -}) - -export type AddFavoriteRoute = typeof addFavorite -export type RemoveFavoriteRoute = typeof removeFavorite -export type RemoveShowFavoriteRoute = typeof removeShowFavorite -export type GetFavoritesRoute = typeof getFavorites diff --git a/docs/migration-effect-http-api-process.md b/docs/migration-effect-http-api-process.md index 8f249ec6..02c57af5 100644 --- a/docs/migration-effect-http-api-process.md +++ b/docs/migration-effect-http-api-process.md @@ -240,3 +240,15 @@ that's been silently corrected is easy to repeat: click is genuinely needed, confirm the exact recipient with the user first (as was done here) and re-verify the UI state immediately beforehand, not just once earlier in the session. +- **Input-side format validation dropped, twice.** `newsletter` (#165) + and `favorites` (#166) both replaced `z.string().email()` / + `z.string().uuid()` with plain `Schema.String`, silently dropping + format validation that used to 400 malformed input before it touched + the DB. Caught by adversarial review both times, not proactively. This + is now a mandatory check for every remaining group, not an + after-the-fact catch: **before opening the PR**, diff every payload and + param field against the old zod schema's validators + (`.email()`, `.uuid()`, `.min()`, `.max()`, enums, refinements) and add + the Effect equivalent (`Schema.String.pipe(Schema.check(Schema.isPattern(regex)))` + for format checks) for anything dropped. Don't wait for review to catch + this a third time. diff --git a/packages/api/package.json b/packages/api/package.json index 3fea0729..68df90e0 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -11,6 +11,7 @@ "./profile": "./src/profile.ts", "./resolve": "./src/resolve.ts", "./invite": "./src/invite.ts", + "./favorites": "./src/favorites.ts", "./errors": "./src/errors.ts", "./testing": "./src/testing.ts", "./middleware/auth": "./src/middleware/auth.ts" diff --git a/packages/api/src/api.ts b/packages/api/src/api.ts index 4a555b16..020a03e9 100644 --- a/packages/api/src/api.ts +++ b/packages/api/src/api.ts @@ -1,5 +1,6 @@ import { HttpApi } from 'effect/unstable/httpapi' import { AdminGroup } from './admin' +import { FavoritesGroup } from './favorites' import { HealthGroup } from './health' import { InternalGroup } from './internal' import { InviteGroup } from './invite' @@ -17,3 +18,4 @@ export const Api = HttpApi.make('gbfm') .add(ResolveGroup) .add(AdminGroup) .add(InviteGroup) + .add(FavoritesGroup) diff --git a/packages/api/src/favorites.ts b/packages/api/src/favorites.ts new file mode 100644 index 00000000..56c1df2f --- /dev/null +++ b/packages/api/src/favorites.ts @@ -0,0 +1,85 @@ +import { Schema } from 'effect' +import { HttpApiEndpoint, HttpApiError, HttpApiGroup } from 'effect/unstable/httpapi' +import { AuthMiddleware } from './middleware/auth' + +const UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const Uuid = Schema.String.pipe(Schema.check(Schema.isPattern(UuidPattern))) + +// Exactly-one-of audioId/showId is enforced in the handler, not the schema. +export const AddFavoriteInput = Schema.Struct({ + audioId: Schema.optional(Uuid), + showId: Schema.optional(Uuid) +}) +export type AddFavoriteInput = typeof AddFavoriteInput.Type + +export const FavoriteActionResponse = Schema.Struct({ + success: Schema.Boolean, + message: Schema.String +}) + +// Mirrors FavoriteService's FavoriteWithContent, apps/vps/src/services/favorite.service.ts. +const FavoriteAudio = Schema.Struct({ + id: Schema.String, + title: Schema.String, + slug: Schema.String, + thumbnailUrl: Schema.NullOr(Schema.String), + type: Schema.Literals(['mix', 'track', 'misc']), + url: Schema.String +}) + +const FavoriteShow = Schema.Struct({ + id: Schema.String, + title: Schema.String, + slug: Schema.String, + thumbnailUrl: Schema.NullOr(Schema.String) +}) + +export const FavoriteWithContent = Schema.Struct({ + id: Schema.String, + userId: Schema.String, + audioId: Schema.NullOr(Schema.String), + showId: Schema.NullOr(Schema.String), + createdAt: Schema.String, + audio: Schema.NullOr(FavoriteAudio), + show: Schema.NullOr(FavoriteShow) +}) + +export const GetFavoritesResponse = Schema.Struct({ + success: Schema.Boolean, + favorites: Schema.Array(FavoriteWithContent), + total: Schema.Number +}) + +const GetFavoritesQuery = { + limit: Schema.optional(Schema.NumberFromString), + offset: Schema.optional(Schema.NumberFromString) +} + +export const FavoritesGroup = HttpApiGroup.make('favorites') + .add( + HttpApiEndpoint.post('addFavorite', '/api/favorites', { + payload: AddFavoriteInput, + success: FavoriteActionResponse, + error: [HttpApiError.BadRequest, HttpApiError.Conflict, HttpApiError.NotFound] + }).middleware(AuthMiddleware) + ) + .add( + HttpApiEndpoint.delete('removeFavorite', '/api/favorites/:audioId', { + params: { audioId: Uuid }, + success: FavoriteActionResponse, + error: HttpApiError.NotFound + }).middleware(AuthMiddleware) + ) + .add( + HttpApiEndpoint.delete('removeShowFavorite', '/api/favorites/show/:showId', { + params: { showId: Uuid }, + success: FavoriteActionResponse, + error: HttpApiError.NotFound + }).middleware(AuthMiddleware) + ) + .add( + HttpApiEndpoint.get('getFavorites', '/api/favorites', { + query: GetFavoritesQuery, + success: GetFavoritesResponse + }).middleware(AuthMiddleware) + )