From 1692b8da46bcb002b661d4c6fbed70537e6aabd8 Mon Sep 17 00:00:00 2001 From: Matyas Mihalka Date: Tue, 6 Oct 2026 16:56:53 +0200 Subject: [PATCH 1/3] Add optional mood to quacks Quacks can carry a mood (happy, sad, angry, silly), chosen in the new-quack form and shown in the feed. Adds the Mood enum and migration, API support, and the shadcn select component. --- .../migration.sql | 5 + apps/backend/prisma/schema.prisma | 10 + .../quack/controllers/dto/create-quack.dto.ts | 18 +- .../controllers/dto/quack.response.dto.ts | 6 +- .../quack/controllers/quacks.controller.ts | 1 + .../backend/src/modules/quack/domain/quack.ts | 4 + .../quack/repositories/quack.repository.ts | 5 +- .../quack/services/quacks.service.spec.ts | 17 ++ .../modules/quack/services/quacks.service.ts | 5 +- apps/frontend/package.json | 1 + apps/frontend/src/components/ui/select.tsx | 187 ++++++++++++++++++ .../src/features/quack/api/addQuack.ts | 4 +- .../src/features/quack/api/quackSchemas.ts | 4 + .../features/quack/components/QuackForm.tsx | 92 +++++++-- .../features/quack/components/QuackItem.tsx | 6 + .../quack/components/QuackList.test.tsx | 15 ++ apps/frontend/src/routes/index.tsx | 2 + pnpm-lock.yaml | 10 + 18 files changed, 366 insertions(+), 26 deletions(-) create mode 100644 apps/backend/prisma/migrations/20261006144241_add_quack_mood/migration.sql create mode 100644 apps/frontend/src/components/ui/select.tsx diff --git a/apps/backend/prisma/migrations/20261006144241_add_quack_mood/migration.sql b/apps/backend/prisma/migrations/20261006144241_add_quack_mood/migration.sql new file mode 100644 index 0000000..3927547 --- /dev/null +++ b/apps/backend/prisma/migrations/20261006144241_add_quack_mood/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "mood" AS ENUM ('happy', 'sad', 'angry', 'silly'); + +-- AlterTable +ALTER TABLE "quack" ADD COLUMN "mood" "mood"; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 70d0525..842c055 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -22,9 +22,19 @@ enum UserRole { @@map("user_role") } +enum Mood { + happy + sad + angry + silly + + @@map("mood") +} + model Quack { id String @id @default(cuid()) text String @db.Text + mood Mood? user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String createdAt DateTime @default(now()) diff --git a/apps/backend/src/modules/quack/controllers/dto/create-quack.dto.ts b/apps/backend/src/modules/quack/controllers/dto/create-quack.dto.ts index fa110aa..36e2970 100644 --- a/apps/backend/src/modules/quack/controllers/dto/create-quack.dto.ts +++ b/apps/backend/src/modules/quack/controllers/dto/create-quack.dto.ts @@ -1,5 +1,12 @@ +import { QUACK_MOODS, QuackMood } from '@/modules/quack/domain/quack'; import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { + IsIn, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; export class CreateQuackDto { @ApiProperty({ @@ -11,4 +18,13 @@ export class CreateQuackDto { @IsNotEmpty() @MaxLength(280) text!: string; + + @ApiProperty({ + description: 'How the quack feels. Optional.', + enum: QUACK_MOODS, + required: false, + }) + @IsOptional() + @IsIn(QUACK_MOODS) + mood?: QuackMood; } diff --git a/apps/backend/src/modules/quack/controllers/dto/quack.response.dto.ts b/apps/backend/src/modules/quack/controllers/dto/quack.response.dto.ts index 8ad9aa9..2e11482 100644 --- a/apps/backend/src/modules/quack/controllers/dto/quack.response.dto.ts +++ b/apps/backend/src/modules/quack/controllers/dto/quack.response.dto.ts @@ -1,4 +1,4 @@ -import { Quack } from '@/modules/quack/domain/quack'; +import { Quack, QUACK_MOODS, QuackMood } from '@/modules/quack/domain/quack'; import { ApiProperty } from '@nestjs/swagger'; class QuackUserDto { @@ -19,6 +19,9 @@ export class QuackResponseDto { @ApiProperty() text!: string; + @ApiProperty({ enum: QUACK_MOODS, nullable: true }) + mood!: QuackMood | null; + @ApiProperty() userId!: string; @@ -37,6 +40,7 @@ export class QuackResponseDto { return { id: quack.id, text: quack.text, + mood: quack.mood, userId: quack.userId, createdAt: quack.createdAt, user: { diff --git a/apps/backend/src/modules/quack/controllers/quacks.controller.ts b/apps/backend/src/modules/quack/controllers/quacks.controller.ts index d38415f..20c7604 100644 --- a/apps/backend/src/modules/quack/controllers/quacks.controller.ts +++ b/apps/backend/src/modules/quack/controllers/quacks.controller.ts @@ -56,6 +56,7 @@ export class QuacksController { ): Promise { const quack = await this.quacksService.createQuack(user, { text: body.text, + mood: body.mood, }); return QuackResponseDto.fromDomain(quack); } diff --git a/apps/backend/src/modules/quack/domain/quack.ts b/apps/backend/src/modules/quack/domain/quack.ts index a7ae02f..062c4d1 100644 --- a/apps/backend/src/modules/quack/domain/quack.ts +++ b/apps/backend/src/modules/quack/domain/quack.ts @@ -1,3 +1,6 @@ +export const QUACK_MOODS = ['happy', 'sad', 'angry', 'silly'] as const; +export type QuackMood = (typeof QUACK_MOODS)[number]; + export type QuackAuthor = { id: string; name: string; @@ -7,6 +10,7 @@ export type QuackAuthor = { export type Quack = { id: string; text: string; + mood: QuackMood | null; userId: string; createdAt: Date; updatedAt: Date; diff --git a/apps/backend/src/modules/quack/repositories/quack.repository.ts b/apps/backend/src/modules/quack/repositories/quack.repository.ts index 67abf82..3d12d0b 100644 --- a/apps/backend/src/modules/quack/repositories/quack.repository.ts +++ b/apps/backend/src/modules/quack/repositories/quack.repository.ts @@ -3,7 +3,7 @@ import { Quack as PrismaQuack, User as PrismaUser, } from '@/generated/prisma/client'; -import { Quack } from '@/modules/quack/domain/quack'; +import { Quack, QuackMood } from '@/modules/quack/domain/quack'; import { Injectable } from '@nestjs/common'; const mapPrismaQuackToDomain = ( @@ -11,6 +11,7 @@ const mapPrismaQuackToDomain = ( ): Quack => ({ id: quack.id, text: quack.text, + mood: quack.mood, userId: quack.userId, createdAt: quack.createdAt, updatedAt: quack.updatedAt, @@ -41,11 +42,13 @@ export class QuackRepository { async createQuack(createQuackData: { text: string; + mood?: QuackMood; userId: string; }): Promise { const quack = await this.prisma.quack.create({ data: { text: createQuackData.text, + mood: createQuackData.mood, user: { connect: { id: createQuackData.userId } }, }, include: { user: true }, diff --git a/apps/backend/src/modules/quack/services/quacks.service.spec.ts b/apps/backend/src/modules/quack/services/quacks.service.spec.ts index 640106f..64bd14a 100644 --- a/apps/backend/src/modules/quack/services/quacks.service.spec.ts +++ b/apps/backend/src/modules/quack/services/quacks.service.spec.ts @@ -9,6 +9,7 @@ import { QuacksService } from './quacks.service'; const aQuack = (overrides: Partial = {}): Quack => ({ id: 'q1', text: 'quack quack', + mood: null, userId: 'u1', createdAt: new Date('2026-01-01T12:00:00Z'), updatedAt: new Date('2026-01-01T12:00:00Z'), @@ -45,4 +46,20 @@ describe('QuacksService', () => { userId: 'u1', }); }); + + it('passes the mood through when one is given', async () => { + const repository = mock(); + repository.createQuack.mockResolvedValue(aQuack({ mood: 'silly' })); + + const service = new QuacksService(repository); + const user = { id: 'u1' } as Identity; + + await service.createQuack(user, { text: 'hello', mood: 'silly' }); + + expect(repository.createQuack).toHaveBeenCalledWith({ + text: 'hello', + mood: 'silly', + userId: 'u1', + }); + }); }); diff --git a/apps/backend/src/modules/quack/services/quacks.service.ts b/apps/backend/src/modules/quack/services/quacks.service.ts index 633772f..dfe9769 100644 --- a/apps/backend/src/modules/quack/services/quacks.service.ts +++ b/apps/backend/src/modules/quack/services/quacks.service.ts @@ -1,4 +1,4 @@ -import { Quack } from '@/modules/quack/domain/quack'; +import { Quack, QuackMood } from '@/modules/quack/domain/quack'; import { QuackRepository } from '@/modules/quack/repositories/quack.repository'; import { Identity } from '@/shared/auth/domain/identity'; import { Injectable } from '@nestjs/common'; @@ -13,10 +13,11 @@ export class QuacksService { async createQuack( user: Identity, - quackData: { text: string }, + quackData: { text: string; mood?: QuackMood }, ): Promise { return this.quackRepository.createQuack({ text: quackData.text, + mood: quackData.mood, // the author is taken from the session, never from the request body userId: user.id, }); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 00bbad9..d79379b 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -24,6 +24,7 @@ "better-auth": "^1.7.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cn": "^0.4.0", "date-fns": "^4.4.0", "ky": "^2.0.2", "lucide-react": "^1.33.0", diff --git a/apps/frontend/src/components/ui/select.tsx b/apps/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..0bca5eb --- /dev/null +++ b/apps/frontend/src/components/ui/select.tsx @@ -0,0 +1,187 @@ +import * as React from "react" +import { cn } from "cn" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import { Select as SelectPrimitive } from "radix-ui" + +function Select({ ...props }: React.ComponentProps) { + return ( + + ) +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ ...props }: React.ComponentProps) { + return ( + + ) +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/apps/frontend/src/features/quack/api/addQuack.ts b/apps/frontend/src/features/quack/api/addQuack.ts index 5af3359..2bf61eb 100644 --- a/apps/frontend/src/features/quack/api/addQuack.ts +++ b/apps/frontend/src/features/quack/api/addQuack.ts @@ -1,8 +1,8 @@ import { api } from "@/lib/api-client" -import { quackSchema, type Quack } from "@/features/quack/api/quackSchemas" +import { quackSchema, type Quack, type QuackMood } from "@/features/quack/api/quackSchemas" -export async function addQuack(input: { text: string }): Promise { +export async function addQuack(input: { text: string; mood?: QuackMood }): Promise { const json = await api.post("quacks", { json: input }).json() return quackSchema.parse(json) } diff --git a/apps/frontend/src/features/quack/api/quackSchemas.ts b/apps/frontend/src/features/quack/api/quackSchemas.ts index 2035bd5..6f15041 100644 --- a/apps/frontend/src/features/quack/api/quackSchemas.ts +++ b/apps/frontend/src/features/quack/api/quackSchemas.ts @@ -8,9 +8,13 @@ export const quackUserSchema = z.object({ username: z.string(), }) +export const quackMoods = ["happy", "sad", "angry", "silly"] as const +export type QuackMood = (typeof quackMoods)[number] + export const quackSchema = z.object({ id: z.string(), text: z.string(), + mood: z.enum(quackMoods).nullable(), userId: z.string(), createdAt: z.coerce.date(), user: quackUserSchema, diff --git a/apps/frontend/src/features/quack/components/QuackForm.tsx b/apps/frontend/src/features/quack/components/QuackForm.tsx index 091e363..dd4c37d 100644 --- a/apps/frontend/src/features/quack/components/QuackForm.tsx +++ b/apps/frontend/src/features/quack/components/QuackForm.tsx @@ -13,21 +13,34 @@ import { FormLabel, FormMessage, } from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Textarea } from "@/components/ui/textarea" import { cn } from "@/lib/utils" +import { quackMoods } from "@/features/quack/api/quackSchemas" import { useAddQuack } from "@/features/quack/hooks/useAddQuack" // Mirrors the server-side DTO (MaxLength(280)) so the user is told before // the request is made — the server still validates independently. const MAX_LENGTH = 280 +// Radix Select can't use "" as an item value, so "no mood" gets a sentinel +// that is mapped to an omitted field on submit. +const NO_MOOD = "none" + const schema = z.object({ text: z .string() .trim() .min(1, "Write something first") .max(MAX_LENGTH, `Keep it under ${MAX_LENGTH} characters`), + mood: z.enum([NO_MOOD, ...quackMoods]), }) type FormValues = z.infer @@ -38,14 +51,20 @@ export function QuackForm({ className }: QuackFormProps) { const addQuack = useAddQuack() const form = useForm({ resolver: zodResolver(schema), - defaultValues: { text: "" }, + defaultValues: { text: "", mood: NO_MOOD }, }) const text = useWatch({ control: form.control, name: "text" }) const length = text?.length ?? 0 const handleSubmit = (values: FormValues) => { - addQuack.mutate({ text: values.text }, { onSuccess: () => form.reset() }) + addQuack.mutate( + { + text: values.text, + mood: values.mood === NO_MOOD ? undefined : values.mood, + }, + { onSuccess: () => form.reset() }, + ) } return ( @@ -61,24 +80,59 @@ export function QuackForm({ className }: QuackFormProps) { ) : null} - ( - - New quack - -