From 833662a64fae89a3436c8390629a7b9416a3903c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 27 Feb 2026 10:51:28 +0000 Subject: [PATCH] feat(api): implement /users/me/preferences endpoint Implements GET/PATCH/PUT /users/me/preferences. Fixes profile page 'Preferences unavailable' error by correcting the /api prefix in frontend calls and adding PATCH handler to controller. Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../src/users/preferences.controller.spec.ts | 112 ++++++++++++++ apps/api/src/users/preferences.controller.ts | 21 ++- .../api/src/users/preferences.service.spec.ts | 141 ++++++++++++++++++ .../src/app/(authenticated)/profile/page.tsx | 2 +- .../settings/appearance/page.tsx | 2 +- 5 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/users/preferences.controller.spec.ts create mode 100644 apps/api/src/users/preferences.service.spec.ts diff --git a/apps/api/src/users/preferences.controller.spec.ts b/apps/api/src/users/preferences.controller.spec.ts new file mode 100644 index 0000000..1ca27fa --- /dev/null +++ b/apps/api/src/users/preferences.controller.spec.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { UnauthorizedException } from "@nestjs/common"; +import { PreferencesController } from "./preferences.controller"; +import { PreferencesService } from "./preferences.service"; +import type { UpdatePreferencesDto, PreferencesResponseDto } from "./dto"; +import type { AuthenticatedRequest } from "../common/types/user.types"; + +describe("PreferencesController", () => { + let controller: PreferencesController; + let service: PreferencesService; + + const mockPreferencesService = { + getPreferences: vi.fn(), + updatePreferences: vi.fn(), + }; + + const mockUserId = "user-uuid-123"; + + const mockPreferencesResponse: PreferencesResponseDto = { + id: "pref-uuid-456", + userId: mockUserId, + theme: "system", + locale: "en", + timezone: null, + settings: {}, + updatedAt: new Date("2026-01-01T00:00:00Z"), + }; + + function makeRequest(userId?: string): AuthenticatedRequest { + return { + user: userId ? { id: userId } : undefined, + } as unknown as AuthenticatedRequest; + } + + beforeEach(() => { + service = mockPreferencesService as unknown as PreferencesService; + controller = new PreferencesController(service); + vi.clearAllMocks(); + }); + + describe("GET /api/users/me/preferences", () => { + it("should return preferences for authenticated user", async () => { + mockPreferencesService.getPreferences.mockResolvedValue(mockPreferencesResponse); + + const result = await controller.getPreferences(makeRequest(mockUserId)); + + expect(result).toEqual(mockPreferencesResponse); + expect(mockPreferencesService.getPreferences).toHaveBeenCalledWith(mockUserId); + }); + + it("should throw UnauthorizedException when user is not authenticated", async () => { + await expect(controller.getPreferences(makeRequest())).rejects.toThrow(UnauthorizedException); + expect(mockPreferencesService.getPreferences).not.toHaveBeenCalled(); + }); + }); + + describe("PUT /api/users/me/preferences", () => { + const updateDto: UpdatePreferencesDto = { + theme: "dark", + locale: "fr", + timezone: "Europe/Paris", + }; + + it("should update and return preferences for authenticated user", async () => { + const updatedResponse: PreferencesResponseDto = { + ...mockPreferencesResponse, + theme: "dark", + locale: "fr", + timezone: "Europe/Paris", + }; + mockPreferencesService.updatePreferences.mockResolvedValue(updatedResponse); + + const result = await controller.updatePreferences(updateDto, makeRequest(mockUserId)); + + expect(result).toEqual(updatedResponse); + expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, updateDto); + }); + + it("should throw UnauthorizedException when user is not authenticated", async () => { + await expect(controller.updatePreferences(updateDto, makeRequest())).rejects.toThrow( + UnauthorizedException + ); + expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled(); + }); + }); + + describe("PATCH /api/users/me/preferences", () => { + const patchDto: UpdatePreferencesDto = { + theme: "light", + }; + + it("should partially update and return preferences for authenticated user", async () => { + const patchedResponse: PreferencesResponseDto = { + ...mockPreferencesResponse, + theme: "light", + }; + mockPreferencesService.updatePreferences.mockResolvedValue(patchedResponse); + + const result = await controller.patchPreferences(patchDto, makeRequest(mockUserId)); + + expect(result).toEqual(patchedResponse); + expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, patchDto); + }); + + it("should throw UnauthorizedException when user is not authenticated", async () => { + await expect(controller.patchPreferences(patchDto, makeRequest())).rejects.toThrow( + UnauthorizedException + ); + expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/users/preferences.controller.ts b/apps/api/src/users/preferences.controller.ts index 166d50c..25ab701 100644 --- a/apps/api/src/users/preferences.controller.ts +++ b/apps/api/src/users/preferences.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Put, + Patch, Body, UseGuards, Request, @@ -38,7 +39,7 @@ export class PreferencesController { /** * PUT /api/users/me/preferences - * Update current user's preferences + * Full replace of current user's preferences */ @Put() async updatePreferences( @@ -53,4 +54,22 @@ export class PreferencesController { return this.preferencesService.updatePreferences(userId, updatePreferencesDto); } + + /** + * PATCH /api/users/me/preferences + * Partial update of current user's preferences + */ + @Patch() + async patchPreferences( + @Body() updatePreferencesDto: UpdatePreferencesDto, + @Request() req: AuthenticatedRequest + ) { + const userId = req.user?.id; + + if (!userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.preferencesService.updatePreferences(userId, updatePreferencesDto); + } } diff --git a/apps/api/src/users/preferences.service.spec.ts b/apps/api/src/users/preferences.service.spec.ts new file mode 100644 index 0000000..48a96ba --- /dev/null +++ b/apps/api/src/users/preferences.service.spec.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { PreferencesService } from "./preferences.service"; +import type { PrismaService } from "../prisma/prisma.service"; +import type { UpdatePreferencesDto } from "./dto"; + +describe("PreferencesService", () => { + let service: PreferencesService; + + const mockPrisma = { + userPreference: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }; + + const mockUserId = "user-uuid-123"; + + const mockDbPreference = { + id: "pref-uuid-456", + userId: mockUserId, + theme: "system", + locale: "en", + timezone: null, + settings: {}, + updatedAt: new Date("2026-01-01T00:00:00Z"), + }; + + beforeEach(() => { + service = new PreferencesService(mockPrisma as unknown as PrismaService); + vi.clearAllMocks(); + }); + + describe("getPreferences", () => { + it("should return existing preferences", async () => { + mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference); + + const result = await service.getPreferences(mockUserId); + + expect(result).toMatchObject({ + id: mockDbPreference.id, + userId: mockUserId, + theme: "system", + locale: "en", + timezone: null, + settings: {}, + }); + expect(mockPrisma.userPreference.findUnique).toHaveBeenCalledWith({ + where: { userId: mockUserId }, + }); + expect(mockPrisma.userPreference.create).not.toHaveBeenCalled(); + }); + + it("should create default preferences when none exist", async () => { + mockPrisma.userPreference.findUnique.mockResolvedValue(null); + mockPrisma.userPreference.create.mockResolvedValue(mockDbPreference); + + const result = await service.getPreferences(mockUserId); + + expect(result).toMatchObject({ + id: mockDbPreference.id, + userId: mockUserId, + theme: "system", + locale: "en", + }); + expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: mockUserId, + theme: "system", + locale: "en", + }), + }); + }); + }); + + describe("updatePreferences", () => { + it("should update existing preferences", async () => { + const updateDto: UpdatePreferencesDto = { theme: "dark", locale: "fr" }; + const updatedPreference = { ...mockDbPreference, theme: "dark", locale: "fr" }; + + mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference); + mockPrisma.userPreference.update.mockResolvedValue(updatedPreference); + + const result = await service.updatePreferences(mockUserId, updateDto); + + expect(result).toMatchObject({ theme: "dark", locale: "fr" }); + expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({ + where: { userId: mockUserId }, + data: expect.objectContaining({ theme: "dark", locale: "fr" }), + }); + expect(mockPrisma.userPreference.create).not.toHaveBeenCalled(); + }); + + it("should create preferences when updating non-existent record", async () => { + const updateDto: UpdatePreferencesDto = { theme: "light" }; + const createdPreference = { ...mockDbPreference, theme: "light" }; + + mockPrisma.userPreference.findUnique.mockResolvedValue(null); + mockPrisma.userPreference.create.mockResolvedValue(createdPreference); + + const result = await service.updatePreferences(mockUserId, updateDto); + + expect(result).toMatchObject({ theme: "light" }); + expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: mockUserId, + theme: "light", + }), + }); + expect(mockPrisma.userPreference.update).not.toHaveBeenCalled(); + }); + + it("should handle timezone update", async () => { + const updateDto: UpdatePreferencesDto = { timezone: "America/New_York" }; + const updatedPreference = { ...mockDbPreference, timezone: "America/New_York" }; + + mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference); + mockPrisma.userPreference.update.mockResolvedValue(updatedPreference); + + const result = await service.updatePreferences(mockUserId, updateDto); + + expect(result.timezone).toBe("America/New_York"); + expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({ + where: { userId: mockUserId }, + data: expect.objectContaining({ timezone: "America/New_York" }), + }); + }); + + it("should handle settings update", async () => { + const updateDto: UpdatePreferencesDto = { settings: { notifications: true } }; + const updatedPreference = { ...mockDbPreference, settings: { notifications: true } }; + + mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference); + mockPrisma.userPreference.update.mockResolvedValue(updatedPreference); + + const result = await service.updatePreferences(mockUserId, updateDto); + + expect(result.settings).toEqual({ notifications: true }); + }); + }); +}); diff --git a/apps/web/src/app/(authenticated)/profile/page.tsx b/apps/web/src/app/(authenticated)/profile/page.tsx index 52bef2c..f60d696 100644 --- a/apps/web/src/app/(authenticated)/profile/page.tsx +++ b/apps/web/src/app/(authenticated)/profile/page.tsx @@ -103,7 +103,7 @@ export default function ProfilePage(): ReactElement { setPrefsError(null); try { - const data = await apiGet("/users/me/preferences"); + const data = await apiGet("/api/users/me/preferences"); setPreferences(data); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Could not load preferences"; diff --git a/apps/web/src/app/(authenticated)/settings/appearance/page.tsx b/apps/web/src/app/(authenticated)/settings/appearance/page.tsx index 3149d9a..6728ea6 100644 --- a/apps/web/src/app/(authenticated)/settings/appearance/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/appearance/page.tsx @@ -240,7 +240,7 @@ export default function AppearanceSettingsPage(): ReactElement { setLocalTheme(themeId); setSaving(true); try { - await apiPatch("/users/me/preferences", { theme: themeId }); + await apiPatch("/api/users/me/preferences", { theme: themeId }); } catch { // Theme is still applied locally even if API save fails } finally {