From fb20dde37dbf278bdd12c1ae49717e01d86e18ed Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Tue, 25 Aug 2026 15:06:24 +0200 Subject: [PATCH] fix: postgresql requires specific syntax for upsert --- .../internal/service/hackathon_service.go | 34 ++++---- .../service/hackathon_service_test.go | 78 +++++++++++++++++++ 2 files changed, 96 insertions(+), 16 deletions(-) diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 52e3b8cf..22e4d4af 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -276,15 +276,15 @@ func (s *HackathonService) Join( } // Check if user already exists in hackathon (approved or waitlisted) - _, err = s.dbClient.Participant.Query().Where( + participant, err := s.dbClient.Participant.Query().Where( entparticipant.HackathonIDEQ(id), entparticipant.UserID(user.ID), ).Only(ctx) + isMember := false if err == nil { - // Already a participant - return success with existing hackathon ID - return &msgs.JoinResponse{HackathonId: h.ID.String()}, nil - } - if !ent.IsNotFound(err) { + // Already a participant - we don't need to insert, just potentially upsert answers + isMember = true + } else if !ent.IsNotFound(err) { slog.Error("check existing participant", "err", err) return nil, status.Error(codes.Internal, "couldn't check participant status") @@ -302,15 +302,17 @@ func (s *HackathonService) Join( return nil, status.Error(codes.Internal, "couldn't start transaction") } - participant, err := tx.Participant.Create(). - SetHackathonID(id). - SetUserID(user.ID). - SetIsWaiting(true). - Save(ctx) - if err != nil { - _ = tx.Rollback() - slog.Error("create participant", "err", err) - return nil, status.Errorf(codes.Internal, "couldn't join hackathon") + if !isMember { + participant, err = tx.Participant.Create(). + SetHackathonID(id). + SetUserID(user.ID). + SetIsWaiting(true). + Save(ctx) + if err != nil { + _ = tx.Rollback() + slog.Error("create participant", "err", err) + return nil, status.Errorf(codes.Internal, "couldn't join hackathon") + } } // Upsert answers linked to the new participant @@ -320,7 +322,7 @@ func (s *HackathonService) Join( SetQuestionID(qID). SetUserID(participant.UserID). SetValue(protoAnswerValueToDB(a)). - OnConflict(). + OnConflictColumns(entanswer.FieldQuestionID, entanswer.FieldUserID). UpdateNewValues(). Exec(ctx) if err != nil { @@ -1542,7 +1544,7 @@ func (s *HackathonService) SubmitAnswers( SetQuestionID(qID). SetUserID(participant.UserID). SetValue(protoAnswerValueToDB(a)). - OnConflict(). + OnConflictColumns(entanswer.FieldQuestionID, entanswer.FieldUserID). UpdateNewValues(). Exec(ctx) if err != nil { diff --git a/components/backend/internal/service/hackathon_service_test.go b/components/backend/internal/service/hackathon_service_test.go index c267bf6b..2377228c 100644 --- a/components/backend/internal/service/hackathon_service_test.go +++ b/components/backend/internal/service/hackathon_service_test.go @@ -363,6 +363,84 @@ var _ = Describe("HackathonService", func() { Expect(participant.IsWaiting).To(BeTrue()) }) + It("upserts answers when user joins twice with different answers", func() { + // Create a question first + token := testutils.CreateTestJWTToken(testAdmin) + adminCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + qResp, err := client.CreateQuestion(adminCtx, &msgs.CreateQuestionRequest{ + HackathonId: createdHackathonID, + Key: "company", + Label: "Company", + Type: entities.QuestionType_QUESTION_TYPE_TEXT, + Mandatory: true, + Order: 1, + }) + Expect(err).NotTo(HaveOccurred()) + questionID := qResp.GetQuestionId() + + // Create a user + joinUserKeycloakID := "join-upsert-user" + joinUser, err := dbClient.User.Create(). + SetKeycloakID(joinUserKeycloakID). + SetUsername("join-upsert-username"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + // Join first time with answer "Acme Corp" + joinToken := testutils.CreateTestJWTToken(joinUserKeycloakID) + joinCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+joinToken), + ) + _, err = client.Join(joinCtx, &msgs.JoinRequest{ + HackathonId: createdHackathonID, + Answers: []*entities.Answer{ + { + QuestionId: questionID, + Value: &entities.Answer_TextValue{TextValue: "Acme Corp"}, + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + // Verify first answer + answers, err := dbClient.Answer.Query(). + Where( + entanswer.QuestionIDEQ(uuid.MustParse(questionID)), + entanswer.UserID(joinUser.ID), + ).All(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(answers).To(HaveLen(1)) + Expect(answers[0].Value).To(Equal("Acme Corp")) + + // Join again with different answer "Globex Inc" + _, err = client.Join(joinCtx, &msgs.JoinRequest{ + HackathonId: createdHackathonID, + Answers: []*entities.Answer{ + { + QuestionId: questionID, + Value: &entities.Answer_TextValue{TextValue: "Globex Inc"}, + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + // Verify answer was upserted (not duplicated) + answers, err = dbClient.Answer.Query(). + Where( + entanswer.QuestionIDEQ(uuid.MustParse(questionID)), + entanswer.UserID(joinUser.ID), + ).All(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(answers).To(HaveLen(1), "should have exactly one answer, not duplicate") + Expect( + answers[0].Value, + ).To(Equal("Globex Inc"), "answer should be updated to new value") + }) + It("returns NOT_FOUND for invalid hackathon ID", func() { token := testutils.CreateTestJWTToken(testAdmin) ctx := metadata.NewOutgoingContext(