From 26f838eeb67e6201ba92f8075ffb0585327e3497 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Thu, 9 Apr 2026 14:10:45 +0900 Subject: [PATCH 1/4] fix questionnaire response counts --- controller/questionnaire.go | 134 +++++++++++++++------- controller/questionnaire_test.go | 189 +++++++++++++++++++++++++++++-- controller/reminder.go | 29 ++++- docs/swagger/swagger.yaml | 15 ++- model/current.go | 2 + model/questionnaires.go | 2 +- model/questionnaires_impl.go | 64 ++++++----- model/questionnaires_test.go | 32 ++++-- model/reminder_targets.go | 97 ++++++++++++++++ model/respondents_impl.go | 7 +- model/v4.go | 15 +++ openapi/server.go | 7 ++ openapi/types.go | 11 +- 13 files changed, 499 insertions(+), 105 deletions(-) create mode 100644 model/reminder_targets.go create mode 100644 model/v4.go diff --git a/controller/questionnaire.go b/controller/questionnaire.go index 90025f44..fce1765f 100644 --- a/controller/questionnaire.go +++ b/controller/questionnaire.go @@ -110,7 +110,9 @@ func formatNumberBound(value *float64) string { } func (q *Questionnaire) GetQuestionnaires(ctx echo.Context, userID string, params openapi.GetQuestionnairesParams) (openapi.QuestionnaireList, error) { - res := openapi.QuestionnaireList{} + res := openapi.QuestionnaireList{ + Questionnaires: []openapi.QuestionnaireSummary{}, + } var sort string if params.Sort == nil { sort = "" @@ -153,50 +155,67 @@ func (q *Questionnaire) GetQuestionnaires(ctx echo.Context, userID string, param var hasMyResponse, hasMyDraft *bool hasMyResponse = params.HasMyResponse hasMyDraft = params.HasMyDraft + countOnly := params.CountOnly != nil && *params.CountOnly - questionnaireList, pageMax, err := q.IQuestionnaire.GetQuestionnaires(ctx.Request().Context(), userID, sort, search, pageNum, onlyTargetingMe, onlyAdministratedByMe, notOverDue, hasMyResponse, hasMyDraft) + questionnaireList, totalRecords, pageMax, err := q.IQuestionnaire.GetQuestionnaires(ctx.Request().Context(), userID, sort, search, pageNum, onlyTargetingMe, onlyAdministratedByMe, notOverDue, hasMyResponse, hasMyDraft, countOnly) if err != nil { return res, err } + res.PageMax = pageMax + res.TotalRecords = totalRecords + if countOnly || len(questionnaireList) == 0 { + return res, nil + } + questionnaireIDs := make([]int, 0, len(questionnaireList)) for _, questionnaire := range questionnaireList { - targets, err := q.ITarget.GetTargets(ctx.Request().Context(), []int{questionnaire.ID}) - if err != nil { - return res, err - } - allRespondend := false - if len(targets) == 0 { - allRespondend = true - } else { - respondents, err := q.IRespondent.GetRespondentsUserIDs(ctx.Request().Context(), []int{questionnaire.ID}) - if err != nil { - return res, err - } - allRespondend = isAllTargetsReponded(targets, respondents) - } + questionnaireIDs = append(questionnaireIDs, questionnaire.ID) + } - hasMyDraft := false - hasMyResponse := false + targets, err := q.ITarget.GetTargets(ctx.Request().Context(), questionnaireIDs) + if err != nil { + return res, err + } + respondents, err := q.IRespondent.GetRespondentsUserIDs(ctx.Request().Context(), questionnaireIDs) + if err != nil { + return res, err + } + myRespondents, err := q.GetRespondentInfos(ctx.Request().Context(), userID, questionnaireIDs...) + if err != nil { + return res, err + } + + targetsByQuestionnaireID := make(map[int][]model.Targets, len(questionnaireList)) + for _, target := range targets { + targetsByQuestionnaireID[target.QuestionnaireID] = append(targetsByQuestionnaireID[target.QuestionnaireID], target) + } + respondentsByQuestionnaireID := make(map[int][]model.Respondents, len(questionnaireList)) + for _, respondent := range respondents { + respondentsByQuestionnaireID[respondent.QuestionnaireID] = append(respondentsByQuestionnaireID[respondent.QuestionnaireID], respondent) + } + myRespondentsByQuestionnaireID := make(map[int][]model.RespondentInfo, len(questionnaireList)) + for _, respondent := range myRespondents { + myRespondentsByQuestionnaireID[respondent.QuestionnaireID] = append(myRespondentsByQuestionnaireID[respondent.QuestionnaireID], respondent) + } + + for _, questionnaire := range questionnaireList { + allRespondend := len(targetsByQuestionnaireID[questionnaire.ID]) == 0 || isAllTargetsReponded(targetsByQuestionnaireID[questionnaire.ID], respondentsByQuestionnaireID[questionnaire.ID]) + hasMyDraftForQuestionnaire := false + hasMyResponseForQuestionnaire := false respondendDateTimeByMe := null.Time{} - myRespondents, err := q.GetRespondentInfos(ctx.Request().Context(), userID, questionnaire.ID) - if err != nil { - return res, err - } - for _, respondent := range myRespondents { + for _, respondent := range myRespondentsByQuestionnaireID[questionnaire.ID] { if !respondent.SubmittedAt.Valid { - hasMyDraft = true + hasMyDraftForQuestionnaire = true + continue } - if respondent.SubmittedAt.Valid { - if !respondendDateTimeByMe.Valid { - respondendDateTimeByMe = respondent.SubmittedAt - } - hasMyResponse = true + if !respondendDateTimeByMe.Valid { + respondendDateTimeByMe = respondent.SubmittedAt } + hasMyResponseForQuestionnaire = true } - res.PageMax = pageMax - res.Questionnaires = append(res.Questionnaires, *questionnaireInfo2questionnaireSummary(questionnaire, allRespondend, hasMyDraft, hasMyResponse, respondendDateTimeByMe)) + res.Questionnaires = append(res.Questionnaires, *questionnaireInfo2questionnaireSummary(questionnaire, allRespondend, hasMyDraftForQuestionnaire, hasMyResponseForQuestionnaire, respondendDateTimeByMe)) } return res, nil } @@ -907,7 +926,13 @@ func (q *Questionnaire) EditQuestionnaire(c echo.Context, questionnaireID int, p func (q *Questionnaire) DeleteQuestionnaire(c echo.Context, questionnaireID int) error { err := q.ITransaction.Do(c.Request().Context(), nil, func(ctx context.Context) error { - err := q.IQuestionnaire.DeleteQuestionnaire(ctx, questionnaireID) + respondentDetails, err := q.GetRespondentDetails(ctx, questionnaireID, "", false, "", nil) + if err != nil { + c.Logger().Errorf("failed to get respondent details: %+v", err) + return err + } + + err = q.IQuestionnaire.DeleteQuestionnaire(ctx, questionnaireID) if err != nil { c.Logger().Errorf("failed to delete questionnaire: %+v", err) return err @@ -983,14 +1008,9 @@ func (q *Questionnaire) DeleteQuestionnaire(c echo.Context, questionnaireID int) } } - respondentDetails, err := q.GetRespondentDetails(ctx, questionnaireID, "", false, "", nil) - if err != nil { - c.Logger().Errorf("failed to get respondent details: %+v", err) - return err - } for _, respondentDetail := range respondentDetails { err = q.IResponse.DeleteResponse(ctx, respondentDetail.ResponseID) - if err != nil { + if err != nil && !errors.Is(err, model.ErrNoRecordDeleted) { c.Logger().Errorf("failed to delete responses: %+v", err) return err } @@ -1002,6 +1022,12 @@ func (q *Questionnaire) DeleteQuestionnaire(c echo.Context, questionnaireID int) } } + err = model.NewReminderTarget().DeleteReminderTargets(ctx, questionnaireID) + if err != nil { + c.Logger().Errorf("failed to delete reminder targets: %+v", err) + return err + } + err = q.DeleteReminder(questionnaireID) if err != nil { c.Logger().Errorf("failed to delete reminder: %+v", err) @@ -1023,6 +1049,24 @@ func (q *Questionnaire) DeleteQuestionnaire(c echo.Context, questionnaireID int) } func (q *Questionnaire) GetQuestionnaireMyRemindStatus(c echo.Context, questionnaireID int, userID string) (bool, error) { + _, _, _, _, _, _, _, _, err := q.GetQuestionnaireInfo(c.Request().Context(), questionnaireID) + if err != nil { + if errors.Is(err, model.ErrRecordNotFound) { + return false, echo.NewHTTPError(http.StatusNotFound, "questionnaire not found") + } + c.Logger().Errorf("failed to get questionnaire info: %+v", err) + return false, echo.NewHTTPError(http.StatusInternalServerError, "failed to check remind status") + } + + reminderTarget, err := model.NewReminderTarget().GetReminderTarget(c.Request().Context(), questionnaireID, userID) + if err == nil { + return !reminderTarget.IsCanceled, nil + } + if err != nil && !errors.Is(err, model.ErrRecordNotFound) { + c.Logger().Errorf("failed to get reminder target status: %+v", err) + return false, echo.NewHTTPError(http.StatusInternalServerError, "failed to check remind status") + } + status, err := q.GetTargetsCancelStatus(c.Request().Context(), questionnaireID, []string{userID}) if err != nil { if errors.Is(err, model.ErrTargetNotFound) { @@ -1036,7 +1080,16 @@ func (q *Questionnaire) GetQuestionnaireMyRemindStatus(c echo.Context, questionn } func (q *Questionnaire) EditQuestionnaireMyRemindStatus(c echo.Context, questionnaireID int, userID string, isRemindEnabled bool) error { - err := q.UpdateTargetsCancelStatus(c.Request().Context(), questionnaireID, []string{userID}, !isRemindEnabled) + _, _, _, _, _, _, _, _, err := q.GetQuestionnaireInfo(c.Request().Context(), questionnaireID) + if err != nil { + if errors.Is(err, model.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "questionnaire not found") + } + c.Logger().Errorf("failed to get questionnaire info: %+v", err) + return echo.NewHTTPError(http.StatusInternalServerError, "failed to update remind status") + } + + err = model.NewReminderTarget().UpsertReminderTarget(c.Request().Context(), questionnaireID, userID, !isRemindEnabled) if err != nil { c.Logger().Errorf("failed to update remind status: %+v", err) return echo.NewHTTPError(http.StatusInternalServerError, "failed to update remind status") @@ -1059,9 +1112,6 @@ func (q *Questionnaire) GetQuestionnaireResponses(c echo.Context, questionnaireI } else { onlyMyResponse = false } - if params.IsDraft == nil || *params.IsDraft { - onlyMyResponse = true - } respondentDetails, err := q.GetRespondentDetails(c.Request().Context(), questionnaireID, sort, onlyMyResponse, userID, params.IsDraft) if err != nil { if errors.Is(err, model.ErrRecordNotFound) { diff --git a/controller/questionnaire_test.go b/controller/questionnaire_test.go index 22034229..7f5fc8ee 100644 --- a/controller/questionnaire_test.go +++ b/controller/questionnaire_test.go @@ -282,6 +282,8 @@ func TestGetQuestionnaires(t *testing.T) { isErr bool err error questionnaireIDList *[]int + totalRecords *int + pageMax *int } type test struct { description string @@ -299,6 +301,8 @@ func TestGetQuestionnaires(t *testing.T) { searchTest := openapi.SearchInQuery(uniqueSearchTitle) largePageNum := 100000000 constTrue := true + totalRecordsTwo := 2 + pageMaxOne := 1 testCases := []test{ { @@ -395,6 +399,8 @@ func TestGetQuestionnaires(t *testing.T) { }, }, expect: expect{ + totalRecords: &totalRecordsTwo, + pageMax: &pageMaxOne, questionnaireIDList: &[]int{ questionnairePosted1.QuestionnaireId, questionnairePosted2.QuestionnaireId, @@ -411,12 +417,45 @@ func TestGetQuestionnaires(t *testing.T) { }, }, expect: expect{ + totalRecords: &totalRecordsTwo, + pageMax: &pageMaxOne, questionnaireIDList: &[]int{ questionnairePosted1.QuestionnaireId, questionnairePosted2.QuestionnaireId, }, }, }, + { + description: "search test count only", + args: args{ + userID: userOne, + params: openapi.GetQuestionnairesParams{ + Search: &searchTest, + CountOnly: &constTrue, + }, + }, + expect: expect{ + totalRecords: &totalRecordsTwo, + pageMax: &pageMaxOne, + questionnaireIDList: &[]int{}, + }, + }, + { + description: "search test count only ignores page", + args: args{ + userID: userOne, + params: openapi.GetQuestionnairesParams{ + Search: &searchTest, + CountOnly: &constTrue, + Page: &largePageNum, + }, + }, + expect: expect{ + totalRecords: &totalRecordsTwo, + pageMax: &pageMaxOne, + questionnaireIDList: &[]int{}, + }, + }, { description: "only targeting me user one", args: args{ @@ -486,6 +525,9 @@ func TestGetQuestionnaires(t *testing.T) { if testCase.args.params.OnlyAdministratedByMe != nil { params.Add("onlyAdministratedByMe", fmt.Sprint(*testCase.args.params.OnlyAdministratedByMe)) } + if testCase.args.params.CountOnly != nil { + params.Add("countOnly", fmt.Sprint(*testCase.args.params.CountOnly)) + } e = echo.New() req = httptest.NewRequest(http.MethodGet, "/questionnaires"+params.Encode(), nil) rec = httptest.NewRecorder() @@ -561,7 +603,7 @@ func TestGetQuestionnaires(t *testing.T) { } if testCase.expect.questionnaireIDList != nil { - var questionnaireIDList []int + questionnaireIDList := []int{} for _, questionnairSummary := range questionnaireList.Questionnaires { questionnaireIDList = append(questionnaireIDList, questionnairSummary.QuestionnaireId) } @@ -571,6 +613,12 @@ func TestGetQuestionnaires(t *testing.T) { sort.Slice(questionnaireIDList, func(i, j int) bool { return questionnaireIDList[i] < questionnaireIDList[j] }) assertion.Equal(*testCase.expect.questionnaireIDList, questionnaireIDList, testCase.description, "questionnaireIDList") } + if testCase.expect.totalRecords != nil { + assertion.Equal(*testCase.expect.totalRecords, questionnaireList.TotalRecords, testCase.description, "totalRecords") + } + if testCase.expect.pageMax != nil { + assertion.Equal(*testCase.expect.pageMax, questionnaireList.PageMax, testCase.description, "pageMax") + } if testCase.args.params.OnlyTargetingMe != nil || testCase.args.params.OnlyAdministratedByMe != nil { for _, questionnaire := range questionnaireList.Questionnaires { @@ -1902,6 +1950,81 @@ func TestDeleteQuestionnaire(t *testing.T) { } } +func TestDeleteQuestionnaireWithResponses(t *testing.T) { + t.Parallel() + + assertion := assert.New(t) + + questionnaire := sampleQuestionnaire + e := echo.New() + body, err := json.Marshal(questionnaire) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/questionnaires", bytes.NewReader(body)) + rec := httptest.NewRecorder() + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + ctx := e.NewContext(req, rec) + questionnaireDetail, err := q.PostQuestionnaire(ctx, questionnaire) + require.NoError(t, err) + + AddQuestionID2SampleResponseMutex.Lock() + AddQuestionID2SampleResponse(questionnaireDetail.QuestionnaireId) + newResponse := sampleResponse + AddQuestionID2SampleResponseMutex.Unlock() + + body, err = json.Marshal(newResponse) + require.NoError(t, err) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/questionnaires/%d/responses", questionnaireDetail.QuestionnaireId), bytes.NewReader(body)) + rec = httptest.NewRecorder() + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + ctx = e.NewContext(req, rec) + _, err = q.PostQuestionnaireResponse(ctx, questionnaireDetail.QuestionnaireId, newResponse, userOne) + require.NoError(t, err) + + req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/questionnaires/%d", questionnaireDetail.QuestionnaireId), nil) + rec = httptest.NewRecorder() + ctx = e.NewContext(req, rec) + + err = q.DeleteQuestionnaire(ctx, questionnaireDetail.QuestionnaireId) + assertion.NoError(err) +} + +func TestDeleteQuestionnaireWithEmptyDraft(t *testing.T) { + t.Parallel() + + assertion := assert.New(t) + + questionnaire := sampleQuestionnaire + e := echo.New() + body, err := json.Marshal(questionnaire) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/questionnaires", bytes.NewReader(body)) + rec := httptest.NewRecorder() + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + ctx := e.NewContext(req, rec) + questionnaireDetail, err := q.PostQuestionnaire(ctx, questionnaire) + require.NoError(t, err) + + emptyDraft := openapi.PostQuestionnaireResponseJSONRequestBody{ + Body: []openapi.NewResponseBody{}, + IsDraft: true, + } + body, err = json.Marshal(emptyDraft) + require.NoError(t, err) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/questionnaires/%d/responses", questionnaireDetail.QuestionnaireId), bytes.NewReader(body)) + rec = httptest.NewRecorder() + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + ctx = e.NewContext(req, rec) + _, err = q.PostQuestionnaireResponse(ctx, questionnaireDetail.QuestionnaireId, emptyDraft, userOne) + require.NoError(t, err) + + req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/questionnaires/%d", questionnaireDetail.QuestionnaireId), nil) + rec = httptest.NewRecorder() + ctx = e.NewContext(req, rec) + + err = q.DeleteQuestionnaire(ctx, questionnaireDetail.QuestionnaireId) + assertion.NoError(err) +} + func TestGetQuestionnaireMyRemindStatus(t *testing.T) { t.Parallel() @@ -1948,9 +2071,39 @@ func TestGetQuestionnaireMyRemindStatus(t *testing.T) { } } -// func TestEditQuestionnaireMyRemindStatus(t *testing.T) { -// // todo -// } +func TestEditQuestionnaireMyRemindStatus(t *testing.T) { + t.Parallel() + + assertion := assert.New(t) + + questionnaire := sampleQuestionnaire + e := echo.New() + body, err := json.Marshal(questionnaire) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/questionnaires", bytes.NewReader(body)) + rec := httptest.NewRecorder() + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + ctx := e.NewContext(req, rec) + questionnaireDetail, err := q.PostQuestionnaire(ctx, questionnaire) + require.NoError(t, err) + + req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/questionnaires/%d/myRemindStatus", questionnaireDetail.QuestionnaireId), nil) + rec = httptest.NewRecorder() + ctx = e.NewContext(req, rec) + err = q.EditQuestionnaireMyRemindStatus(ctx, questionnaireDetail.QuestionnaireId, userOne, true) + require.NoError(t, err) + + status, err := q.GetQuestionnaireMyRemindStatus(ctx, questionnaireDetail.QuestionnaireId, userOne) + require.NoError(t, err) + assertion.True(status, "non target user can opt in") + + err = q.EditQuestionnaireMyRemindStatus(ctx, questionnaireDetail.QuestionnaireId, userThree, false) + require.NoError(t, err) + + status, err = q.GetQuestionnaireMyRemindStatus(ctx, questionnaireDetail.QuestionnaireId, userThree) + require.NoError(t, err) + assertion.False(status, "target user can opt out via override") +} func TestGetQuestionnaireResponses(t *testing.T) { t.Parallel() @@ -2014,7 +2167,19 @@ func TestGetQuestionnaireResponses(t *testing.T) { rec = httptest.NewRecorder() req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) ctx = e.NewContext(req, rec) - _, err = q.PostQuestionnaireResponse(ctx, questionnaireDetail.QuestionnaireId, newResponse, userTwo) + responseUserTwo, err := q.PostQuestionnaireResponse(ctx, questionnaireDetail.QuestionnaireId, newResponse, userTwo) + require.NoError(t, err) + + newResponse = sampleResponse + newResponse.IsDraft = true + e = echo.New() + body, err = json.Marshal(newResponse) + require.NoError(t, err) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/questionnaires/%d/responses", questionnaireDetail.QuestionnaireId), bytes.NewReader(body)) + rec = httptest.NewRecorder() + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + ctx = e.NewContext(req, rec) + response03, err := q.PostQuestionnaireResponse(ctx, questionnaireDetail.QuestionnaireId, newResponse, userTwo) require.NoError(t, err) questionnaireAnonymous := sampleQuestionnaire @@ -2050,7 +2215,7 @@ func TestGetQuestionnaireResponses(t *testing.T) { rec = httptest.NewRecorder() req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) ctx = e.NewContext(req, rec) - _, err = q.PostQuestionnaireResponse(ctx, questionnaireAnonymousDetail.QuestionnaireId, newResponse, userTwo) + response11, err := q.PostQuestionnaireResponse(ctx, questionnaireAnonymousDetail.QuestionnaireId, newResponse, userTwo) require.NoError(t, err) AddQuestionID2SampleResponseMutex.Unlock() @@ -2084,7 +2249,7 @@ func TestGetQuestionnaireResponses(t *testing.T) { testCases := []test{ { - description: "valid", + description: "valid returns all responses by default", args: args{ userID: userOne, questionnaireID: questionnaireDetail.QuestionnaireId, @@ -2095,6 +2260,8 @@ func TestGetQuestionnaireResponses(t *testing.T) { response00.ResponseId, response01.ResponseId, response02.ResponseId, + responseUserTwo.ResponseId, + response03.ResponseId, }, }, }, @@ -2202,7 +2369,7 @@ func TestGetQuestionnaireResponses(t *testing.T) { }, }, { - description: "isDraft true forces only my response", + description: "isDraft true does not force only my response", args: args{ userID: userOne, questionnaireID: questionnaireDetail.QuestionnaireId, @@ -2214,11 +2381,12 @@ func TestGetQuestionnaireResponses(t *testing.T) { expect: expect{ responseIDList: &[]int{ response02.ResponseId, + response03.ResponseId, }, }, }, { - description: "anonymous questionnaire", + description: "anonymous questionnaire returns all responses by default", args: args{ isAnonymousQuestionnaire: true, userID: userOne, @@ -2228,6 +2396,7 @@ func TestGetQuestionnaireResponses(t *testing.T) { expect: expect{ responseIDList: &[]int{ response10.ResponseId, + response11.ResponseId, }, }, }, @@ -2327,7 +2496,7 @@ func TestGetQuestionnaireResponses(t *testing.T) { assertion.Equal(*testCase.expect.responseIDList, responseIDList, testCase.description, "responseIDList") } - if testCase.args.params.OnlyMyResponse != nil { + if testCase.args.params.OnlyMyResponse != nil && *testCase.args.params.OnlyMyResponse { for _, response := range responseList { assertion.Equal(*response.Respondent, testCase.args.userID, testCase.description, "OnlyMyResponse") } diff --git a/controller/reminder.go b/controller/reminder.go index e96f9012..f35aafa7 100644 --- a/controller/reminder.go +++ b/controller/reminder.go @@ -3,7 +3,7 @@ package controller import ( "context" "log" - "slices" + "sort" "sync" "time" @@ -206,16 +206,35 @@ func reminderAction(questionnaireID int, leftTimeText string) error { return err } - var reminderTargets []string + reminderTargetOverrides, err := model.NewReminderTarget().GetReminderTargets(ctx, questionnaireID) + if err != nil { + return err + } + + respondantSet := make(map[string]struct{}, len(respondants)) + for _, respondent := range respondants { + respondantSet[respondent] = struct{}{} + } + + reminderTargetMap := make(map[string]bool, len(questionnaire.Targets)+len(reminderTargetOverrides)) for _, target := range questionnaire.Targets { - if target.IsCanceled { + reminderTargetMap[target.UserTraqid] = !target.IsCanceled + } + for _, target := range reminderTargetOverrides { + reminderTargetMap[target.UserTraqid] = !target.IsCanceled + } + + var reminderTargets []string + for userID, isEnabled := range reminderTargetMap { + if !isEnabled { continue } - if slices.Contains(respondants, target.UserTraqid) { + if _, ok := respondantSet[userID]; ok { continue } - reminderTargets = append(reminderTargets, target.UserTraqid) + reminderTargets = append(reminderTargets, userID) } + sort.Strings(reminderTargets) reminderMessage := createReminderMessage(questionnaireID, questionnaire.Title, questionnaire.Description, administrators, questionnaire.ResTimeLimit.Time, reminderTargets, leftTimeText) wh := traq.NewWebhook() diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 651327b1..846c6671 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -37,6 +37,7 @@ paths: # TODO 変数の命名を確認する - $ref: "#/components/parameters/notOverDueInQuery" - $ref: "#/components/parameters/hasMyResponseInQuery" - $ref: "#/components/parameters/hasMyDraftInQuery" + - $ref: "#/components/parameters/countOnlyInQuery" responses: "200": description: 正常に取得できました。アンケートの配列を返します。 @@ -449,7 +450,6 @@ components: in: query description: | trueの場合、下書きのみを取得する。falseの場合、下書きではないもののみを取得する。存在しない場合はすべて取得する。 - /questionnaires/{questionnaireID}/responses では、trueまたは未指定の場合 onlyMyResponse も true として扱う。 schema: type: boolean notOverDueInQuery: @@ -477,6 +477,13 @@ components: 存在しない場合、すべてのアンケートを取得する。 schema: type: boolean + countOnlyInQuery: + name: countOnly + in: query + description: | + trueの場合、questionnaires は空配列で返し、件数情報のみ取得する。page は無視される。 + schema: + type: boolean questionnaireIDsInQuery: name: questionnaireIDs in: query @@ -659,12 +666,18 @@ components: example: 1 description: | 合計のページ数 + total_records: + type: integer + example: 42 + description: | + 現在の検索条件に一致するアンケートの総件数 questionnaires: type: array items: $ref: "#/components/schemas/QuestionnaireSummary" required: - page_max + - total_records - questionnaires QuestionnaireID: type: object diff --git a/model/current.go b/model/current.go index 7efb58ee..32cbe58b 100644 --- a/model/current.go +++ b/model/current.go @@ -8,6 +8,7 @@ import ( func Migrations() []*gormigrate.Migration { return []*gormigrate.Migration{ v3(), + v4(), } } @@ -25,6 +26,7 @@ func AllTables() []interface{} { &Targets{}, &TargetUsers{}, &TargetGroups{}, + &ReminderTargets{}, &Validations{}, } } diff --git a/model/questionnaires.go b/model/questionnaires.go index bcf07bca..30a74b6a 100644 --- a/model/questionnaires.go +++ b/model/questionnaires.go @@ -14,7 +14,7 @@ type IQuestionnaire interface { InsertQuestionnaire(ctx context.Context, title string, description string, resTimeLimit null.Time, resSharedTo string, isPublished bool, isAnonymous bool, isDuplicateAnswerAllowed bool) (int, error) UpdateQuestionnaire(ctx context.Context, title string, description string, resTimeLimit null.Time, resSharedTo string, questionnaireID int, isPublished bool, isAnonymous bool, isDuplicateAnswerAllowed bool) error DeleteQuestionnaire(ctx context.Context, questionnaireID int) error - GetQuestionnaires(ctx context.Context, userID string, sort string, search string, pageNum int, onlyTargetingMe bool, onlyAdministratedByMe bool, notOverDue bool, hasMyResponse *bool, hasMyDraft *bool) ([]QuestionnaireInfo, int, error) + GetQuestionnaires(ctx context.Context, userID string, sort string, search string, pageNum int, onlyTargetingMe bool, onlyAdministratedByMe bool, notOverDue bool, hasMyResponse *bool, hasMyDraft *bool, countOnly bool) ([]QuestionnaireInfo, int, int, error) GetAdminQuestionnaires(ctx context.Context, userID string) ([]Questionnaires, error) GetQuestionnaireInfo(ctx context.Context, questionnaireID int) (*Questionnaires, []string, []string, []uuid.UUID, []string, []string, []uuid.UUID, []string, error) GetTargettedQuestionnaires(ctx context.Context, userID string, answered string, sort string) ([]TargettedQuestionnaire, error) diff --git a/model/questionnaires_impl.go b/model/questionnaires_impl.go index ce77bcbb..7c470213 100755 --- a/model/questionnaires_impl.go +++ b/model/questionnaires_impl.go @@ -179,29 +179,16 @@ func (*Questionnaire) DeleteQuestionnaire(ctx context.Context, questionnaireID i return nil } -/* -GetQuestionnaires アンケートの一覧 -2つ目の戻り値はページ数の最大値 -*/ -func (*Questionnaire) GetQuestionnaires(ctx context.Context, userID string, sort string, search string, pageNum int, onlyTargetingMe bool, onlyAdministratedByMe bool, notOverDue bool, hasMyResponse *bool, hasMyDraft *bool) ([]QuestionnaireInfo, int, error) { - ctx, cancel := context.WithTimeout(ctx, 3*time.Second) - defer cancel() - - db, err := getTx(ctx) - if err != nil { - return nil, 0, fmt.Errorf("failed to get tx: %w", err) - } - - questionnaires := make([]QuestionnaireInfo, 0, 20) - +func buildQuestionnairesQuery(db *gorm.DB, userID string, sort string, search string, onlyTargetingMe bool, onlyAdministratedByMe bool, notOverDue bool, hasMyResponse *bool, hasMyDraft *bool) (*gorm.DB, error) { query := db. Table("questionnaires"). Where("deleted_at IS NULL AND is_published IS TRUE"). Joins("LEFT OUTER JOIN targets ON questionnaires.id = targets.questionnaire_id") + var err error query, err = setQuestionnairesOrder(query, sort) if err != nil { - return nil, 0, fmt.Errorf("failed to set the order of the questionnaire table: %w", err) + return nil, fmt.Errorf("failed to set the order of the questionnaire table: %w", err) } if onlyTargetingMe { @@ -236,34 +223,59 @@ func (*Questionnaire) GetQuestionnaires(ctx context.Context, userID string, sort if len(search) != 0 { // MySQLでのregexpの構文は少なくともGoのregexpの構文でvalidである必要がある - _, err := regexp.Compile(search) - if err != nil { - return nil, 0, fmt.Errorf("invalid search param: %w", ErrInvalidRegex) + if _, err := regexp.Compile(search); err != nil { + return nil, fmt.Errorf("invalid search param: %w", ErrInvalidRegex) } // BINARYをつけていないので大文字小文字区別しない query = query.Where("questionnaires.title REGEXP ?", search) } + return query, nil +} + +/* +GetQuestionnaires アンケートの一覧 +2つ目の戻り値は対象件数、3つ目の戻り値はページ数の最大値 +*/ +func (*Questionnaire) GetQuestionnaires(ctx context.Context, userID string, sort string, search string, pageNum int, onlyTargetingMe bool, onlyAdministratedByMe bool, notOverDue bool, hasMyResponse *bool, hasMyDraft *bool, countOnly bool) ([]QuestionnaireInfo, int, int, error) { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + db, err := getTx(ctx) + if err != nil { + return nil, 0, 0, fmt.Errorf("failed to get tx: %w", err) + } + + questionnaires := make([]QuestionnaireInfo, 0, 20) + query, err := buildQuestionnairesQuery(db, userID, sort, search, onlyTargetingMe, onlyAdministratedByMe, notOverDue, hasMyResponse, hasMyDraft) + if err != nil { + return nil, 0, 0, err + } + var count int64 err = query. Session(&gorm.Session{}). Group("questionnaires.id"). Count(&count).Error if errors.Is(err, context.DeadlineExceeded) { - return nil, 0, ErrDeadlineExceeded + return nil, 0, 0, ErrDeadlineExceeded } if err != nil { - return nil, 0, fmt.Errorf("failed to retrieve the number of questionnaires: %w", err) + return nil, 0, 0, fmt.Errorf("failed to retrieve the number of questionnaires: %w", err) } if count == 0 { - return []QuestionnaireInfo{}, 0, nil + return []QuestionnaireInfo{}, 0, 0, nil } pageMax := (int(count) + 19) / 20 + if countOnly { + return []QuestionnaireInfo{}, int(count), pageMax, nil + } + if pageNum > pageMax { - return nil, 0, fmt.Errorf("failed to set page offset: %w", ErrTooLargePageNum) + return nil, 0, 0, fmt.Errorf("failed to set page offset: %w", ErrTooLargePageNum) } offset := (pageNum - 1) * 20 @@ -275,13 +287,13 @@ func (*Questionnaire) GetQuestionnaires(ctx context.Context, userID string, sort Group("questionnaires.id"). Find(&questionnaires).Error if errors.Is(err, context.DeadlineExceeded) { - return nil, 0, ErrDeadlineExceeded + return nil, 0, 0, ErrDeadlineExceeded } if err != nil { - return nil, 0, fmt.Errorf("failed to get the targeted questionnaires: %w", err) + return nil, 0, 0, fmt.Errorf("failed to get the targeted questionnaires: %w", err) } - return questionnaires, pageMax, nil + return questionnaires, int(count), pageMax, nil } // GetAdminQuestionnaires 自分が管理者のアンケートの取得 diff --git a/model/questionnaires_test.go b/model/questionnaires_test.go index 57febef4..398a6962 100644 --- a/model/questionnaires_test.go +++ b/model/questionnaires_test.go @@ -3,7 +3,6 @@ package model import ( "context" "errors" - "fmt" "math" "sort" "strings" @@ -1145,6 +1144,7 @@ func getQuestionnairesTest(t *testing.T) { pageNum int onlyTargetingMe bool onlyAdministratedByMe bool + countOnly bool } type expect struct { isErr bool @@ -1336,6 +1336,18 @@ func getQuestionnairesTest(t *testing.T) { err: ErrTooLargePageNum, }, }, + { + description: "count only ignores page", + args: args{ + userID: questionnairesTestUserID, + sort: "", + search: "", + pageNum: 100000, + onlyTargetingMe: false, + onlyAdministratedByMe: false, + countOnly: true, + }, + }, { description: "userID:valid, sort:no, search:notFoundQuestionnaire, page:1", args: args{ @@ -1371,7 +1383,7 @@ func getQuestionnairesTest(t *testing.T) { for _, testCase := range testCases { ctx := context.Background() - questionnaires, pageMax, err := questionnaireImpl.GetQuestionnaires(ctx, testCase.args.userID, testCase.args.sort, testCase.args.search, testCase.args.pageNum, testCase.args.onlyTargetingMe, testCase.args.onlyAdministratedByMe, false, nil, nil) + questionnaires, totalRecords, pageMax, err := questionnaireImpl.GetQuestionnaires(ctx, testCase.args.userID, testCase.args.sort, testCase.args.search, testCase.args.pageNum, testCase.args.onlyTargetingMe, testCase.args.onlyAdministratedByMe, false, nil, nil, testCase.args.countOnly) if !testCase.expect.isErr { assertion.NoError(err, testCase.description, "no error") @@ -1417,17 +1429,13 @@ func getQuestionnairesTest(t *testing.T) { } if len(testCase.args.search) == 0 && !testCase.args.onlyTargetingMe && !testCase.args.onlyAdministratedByMe { - fmt.Println(testCase.description) - fmt.Println(questionnaireNum) - fmt.Println(pageMax) - var allNum int64 - db. - Session(&gorm.Session{NewDB: true}). - Model(&Questionnaires{}). - Count(&allNum) - fmt.Println(allNum) + assertion.Equal(int(questionnaireNum), totalRecords, testCase.description, "totalRecords") assertion.Equal((questionnaireNum+19)/20, int64(pageMax), testCase.description, "pageMax") - assertion.Len(questionnaires, int(math.Min(float64(questionnaireNum-20*(int64(testCase.pageNum)-1)), 20.0)), testCase.description, "page") + if testCase.args.countOnly { + assertion.Empty(questionnaires, testCase.description, "countOnly") + } else { + assertion.Len(questionnaires, int(math.Min(float64(questionnaireNum-20*(int64(testCase.pageNum)-1)), 20.0)), testCase.description, "page") + } } if testCase.expect.isCheckLen { diff --git a/model/reminder_targets.go b/model/reminder_targets.go new file mode 100644 index 00000000..d53c6c4f --- /dev/null +++ b/model/reminder_targets.go @@ -0,0 +1,97 @@ +package model + +import ( + "context" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type ReminderTarget struct{} + +func NewReminderTarget() *ReminderTarget { + return new(ReminderTarget) +} + +type ReminderTargets struct { + QuestionnaireID int `gorm:"type:int(11) AUTO_INCREMENT;not null;primaryKey"` + UserTraqid string `gorm:"type:varchar(32);size:32;not null;primaryKey"` + IsCanceled bool `gorm:"type:tinyint(1);not null;default:0"` +} + +func (*ReminderTarget) GetReminderTarget(ctx context.Context, questionnaireID int, userID string) (*ReminderTargets, error) { + db, err := getTx(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get transaction: %w", err) + } + + reminderTarget := ReminderTargets{} + err = db. + Where("questionnaire_id = ? AND user_traqid = ?", questionnaireID, userID). + First(&reminderTarget).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrRecordNotFound + } + return nil, fmt.Errorf("failed to get reminder target: %w", err) + } + + return &reminderTarget, nil +} + +func (*ReminderTarget) GetReminderTargets(ctx context.Context, questionnaireID int) ([]ReminderTargets, error) { + db, err := getTx(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get transaction: %w", err) + } + + reminderTargets := []ReminderTargets{} + err = db. + Where("questionnaire_id = ?", questionnaireID). + Find(&reminderTargets).Error + if err != nil { + return nil, fmt.Errorf("failed to get reminder targets: %w", err) + } + + return reminderTargets, nil +} + +func (*ReminderTarget) UpsertReminderTarget(ctx context.Context, questionnaireID int, userID string, isCanceled bool) error { + db, err := getTx(ctx) + if err != nil { + return fmt.Errorf("failed to get transaction: %w", err) + } + + err = db. + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "questionnaire_id"}, {Name: "user_traqid"}}, + DoUpdates: clause.AssignmentColumns([]string{"is_canceled"}), + }). + Create(&ReminderTargets{ + QuestionnaireID: questionnaireID, + UserTraqid: userID, + IsCanceled: isCanceled, + }).Error + if err != nil { + return fmt.Errorf("failed to upsert reminder target: %w", err) + } + + return nil +} + +func (*ReminderTarget) DeleteReminderTargets(ctx context.Context, questionnaireID int) error { + db, err := getTx(ctx) + if err != nil { + return fmt.Errorf("failed to get transaction: %w", err) + } + + err = db. + Where("questionnaire_id = ?", questionnaireID). + Delete(&ReminderTargets{}).Error + if err != nil { + return fmt.Errorf("failed to delete reminder targets: %w", err) + } + + return nil +} diff --git a/model/respondents_impl.go b/model/respondents_impl.go index 5eb713a3..c5a5da25 100755 --- a/model/respondents_impl.go +++ b/model/respondents_impl.go @@ -226,13 +226,8 @@ func (*Respondent) GetRespondentInfos(ctx context.Context, userID string, questi Order("respondents.submitted_at DESC"). Where("user_traqid = ? AND respondents.deleted_at IS NULL AND questionnaires.deleted_at IS NULL", userID) - if len(questionnaireIDs) > 1 { - // 空配列か1要素の取得にしか用いない - return nil, errors.New("illegal function usage") - } if len(questionnaireIDs) != 0 { - questionnaireID := questionnaireIDs[0] - query = query.Where("questionnaire_id = ?", questionnaireID) + query = query.Where("questionnaire_id IN (?)", questionnaireIDs) } err = query. diff --git a/model/v4.go b/model/v4.go new file mode 100644 index 00000000..45c78847 --- /dev/null +++ b/model/v4.go @@ -0,0 +1,15 @@ +package model + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +func v4() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "4", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate(&ReminderTargets{}) + }, + } +} diff --git a/openapi/server.go b/openapi/server.go index 4ddadc0c..d10d19a5 100644 --- a/openapi/server.go +++ b/openapi/server.go @@ -136,6 +136,13 @@ func (w *ServerInterfaceWrapper) GetQuestionnaires(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hasMyDraft: %s", err)) } + // ------------- Optional query parameter "countOnly" ------------- + + err = runtime.BindQueryParameter("form", true, false, "countOnly", ctx.QueryParams(), ¶ms.CountOnly) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter countOnly: %s", err)) + } + // Invoke the callback with all the unmarshaled arguments err = w.Handler.GetQuestionnaires(ctx, params) return err diff --git a/openapi/types.go b/openapi/types.go index 444b6a3e..ee031746 100644 --- a/openapi/types.go +++ b/openapi/types.go @@ -471,6 +471,9 @@ type QuestionnaireList struct { // PageMax 合計のページ数 PageMax int `json:"page_max"` Questionnaires []QuestionnaireSummary `json:"questionnaires"` + + // TotalRecords 条件に一致するアンケートの総件数 + TotalRecords int `json:"total_records"` } // QuestionnaireModifiedAt defines model for QuestionnaireModifiedAt. @@ -723,6 +726,9 @@ type UsersAndGroups struct { // HasMyDraftInQuery defines model for hasMyDraftInQuery. type HasMyDraftInQuery = bool +// CountOnlyInQuery defines model for countOnlyInQuery. +type CountOnlyInQuery = bool + // HasMyResponseInQuery defines model for hasMyResponseInQuery. type HasMyResponseInQuery = bool @@ -791,6 +797,9 @@ type GetQuestionnairesParams struct { // falseの場合、自分の回答の下書きが存在しないアンケートのみを取得する。 // 存在しない場合、すべてのアンケートを取得する。 HasMyDraft *HasMyDraftInQuery `form:"hasMyDraft,omitempty" json:"hasMyDraft,omitempty"` + + // CountOnly trueの場合、questionnaires は空配列で返し、件数情報のみ取得する。page は無視される。 + CountOnly *CountOnlyInQuery `form:"countOnly,omitempty" json:"countOnly,omitempty"` } // GetQuestionnaireResponsesParams defines parameters for GetQuestionnaireResponses. @@ -802,7 +811,6 @@ type GetQuestionnaireResponsesParams struct { OnlyMyResponse *OnlyMyResponseInQuery `form:"onlyMyResponse,omitempty" json:"onlyMyResponse,omitempty"` // IsDraft trueの場合、下書きのみを取得する。falseの場合、下書きではないもののみを取得する。存在しない場合はすべて取得する。 - // /questionnaires/{questionnaireID}/responses では、trueまたは未指定の場合 onlyMyResponse も true として扱う。 IsDraft *IsDraftInQuery `form:"isDraft,omitempty" json:"isDraft,omitempty"` } @@ -815,7 +823,6 @@ type GetMyResponsesParams struct { QuestionnaireIDs *QuestionnaireIDsInQuery `form:"questionnaireIDs,omitempty" json:"questionnaireIDs,omitempty"` // IsDraft trueの場合、下書きのみを取得する。falseの場合、下書きではないもののみを取得する。存在しない場合はすべて取得する。 - // /questionnaires/{questionnaireID}/responses では、trueまたは未指定の場合 onlyMyResponse も true として扱う。 IsDraft *IsDraftInQuery `form:"isDraft,omitempty" json:"isDraft,omitempty"` } From 60c34c7adff3936181f45b335bdae0559d1a6393 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Thu, 9 Apr 2026 17:07:57 +0900 Subject: [PATCH 2/4] fix own response result auth --- controller/middleware.go | 12 ++++ controller/middleware_test.go | 126 ++++++++++++++++++++++++++++++++++ main.go | 2 +- 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/controller/middleware.go b/controller/middleware.go index 5d0a6957..cc2655dc 100644 --- a/controller/middleware.go +++ b/controller/middleware.go @@ -338,6 +338,18 @@ func (m Middleware) ResultAuthenticate(next echo.HandlerFunc) echo.HandlerFunc { } } +// ResultOrMyResponseAuthenticate 全体結果か自分の回答一覧かに応じて認証を切り替える +func (m Middleware) ResultOrMyResponseAuthenticate(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + onlyMyResponse, err := strconv.ParseBool(c.QueryParam("onlyMyResponse")) + if err == nil && onlyMyResponse { + return m.QuestionnaireReadAuthenticate(next)(c) + } + + return m.ResultAuthenticate(next)(c) + } +} + // GetUserID ユーザーIDを取得する func (m *Middleware) GetUserID(c echo.Context) (string, error) { rowUserID := c.Get(userIDKey) diff --git a/controller/middleware_test.go b/controller/middleware_test.go index 8d4bdd0a..5bf2946e 100644 --- a/controller/middleware_test.go +++ b/controller/middleware_test.go @@ -634,3 +634,129 @@ func TestCheckResponseReadPrivilege(t *testing.T) { } } } + +func TestResultOrMyResponseAuthenticate(t *testing.T) { + t.Parallel() + + assertion := assert.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRespondent := mock_model.NewMockIRespondent(ctrl) + mockAdministrator := mock_model.NewMockIAdministrator(ctrl) + mockQuestionnaire := mock_model.NewMockIQuestionnaire(ctrl) + mockQuestion := mock_model.NewMockIQuestion(ctrl) + + middleware := NewMiddleware(mockAdministrator, mockRespondent, mockQuestion, mockQuestionnaire) + + type args struct { + query string + isPublished bool + resultPrivilege model.ResponseReadPrivilegeInfo + } + type expect struct { + statusCode int + isCalled bool + } + type test struct { + description string + args + expect + } + + testCases := []test{ + { + description: "onlyMyResponse=trueなら全体結果権限がなくても公開済みアンケートを読めれば通す", + args: args{ + query: "?onlyMyResponse=true", + isPublished: true, + resultPrivilege: model.ResponseReadPrivilegeInfo{ + ResSharedTo: "administrators", + }, + }, + expect: expect{ + statusCode: http.StatusOK, + isCalled: true, + }, + }, + { + description: "onlyMyResponse=trueでも未公開アンケートなら403", + args: args{ + query: "?onlyMyResponse=true", + isPublished: false, + resultPrivilege: model.ResponseReadPrivilegeInfo{ + ResSharedTo: "public", + }, + }, + expect: expect{ + statusCode: http.StatusForbidden, + isCalled: false, + }, + }, + { + description: "onlyMyResponse=falseなら従来通り全体結果権限を見る", + args: args{ + query: "?onlyMyResponse=false", + isPublished: true, + resultPrivilege: model.ResponseReadPrivilegeInfo{ + ResSharedTo: "administrators", + }, + }, + expect: expect{ + statusCode: http.StatusForbidden, + isCalled: false, + }, + }, + { + description: "onlyMyResponse未指定なら従来通り全体結果権限を見る", + args: args{ + isPublished: true, + resultPrivilege: model.ResponseReadPrivilegeInfo{ + ResSharedTo: "public", + }, + }, + expect: expect{ + statusCode: http.StatusOK, + isCalled: true, + }, + }, + } + + for _, testCase := range testCases { + userID := "testUser" + questionnaireID := 1 + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/questionnaires/%d/responses%s", questionnaireID, testCase.args.query), nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/questionnaires/:questionnaireID/responses") + c.SetParamNames("questionnaireID") + c.SetParamValues(strconv.Itoa(questionnaireID)) + c.Set(userIDKey, userID) + + if testCase.args.query == "?onlyMyResponse=true" { + mockAdministrator. + EXPECT(). + CheckQuestionnaireAdmin(c.Request().Context(), userID, questionnaireID). + Return(false, nil) + mockQuestionnaire. + EXPECT(). + GetQuestionnaireInfo(c.Request().Context(), questionnaireID). + Return(&model.Questionnaires{IsPublished: testCase.args.isPublished}, nil, nil, nil, nil, nil, nil, nil, nil) + } else { + mockQuestionnaire. + EXPECT(). + GetResponseReadPrivilegeInfoByQuestionnaireID(c.Request().Context(), userID, questionnaireID). + Return(&testCase.args.resultPrivilege, nil) + } + + callChecker := CallChecker{} + + e.HTTPErrorHandler(middleware.ResultOrMyResponseAuthenticate(callChecker.Handler)(c), c) + + assertion.Equalf(testCase.expect.statusCode, rec.Code, testCase.description, "status code") + assertion.Equalf(testCase.expect.isCalled, callChecker.IsCalled, testCase.description, "isCalled") + } +} diff --git a/main.go b/main.go index d22b56d3..e1e5357a 100644 --- a/main.go +++ b/main.go @@ -62,7 +62,7 @@ func main() { mws.AddRouteConfig("/api/questionnaires/:questionnaireID", http.MethodPatch, api.Middleware.QuestionnaireAdministratorAuthenticate) mws.AddRouteConfig("/api/questionnaires/:questionnaireID", http.MethodDelete, api.Middleware.QuestionnaireAdministratorAuthenticate) mws.AddRouteConfig("/api/questionnaires/:questionnaireID/responses", http.MethodPost, api.Middleware.QuestionnaireReadAuthenticate) - mws.AddRouteConfig("/api/questionnaires/:questionnaireID/responses", http.MethodGet, api.Middleware.ResultAuthenticate) + mws.AddRouteConfig("/api/questionnaires/:questionnaireID/responses", http.MethodGet, api.Middleware.ResultOrMyResponseAuthenticate) mws.AddRouteConfig("/api/responses/:responseID", http.MethodGet, api.Middleware.ResponseReadAuthenticate) mws.AddRouteConfig("/api/responses/:responseID", http.MethodPatch, api.Middleware.RespondentAuthenticate) From 6a6cc5afac8a7a1ca77c011e2d7e86c014aa05c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:12:09 +0900 Subject: [PATCH 3/4] chore: regenerate generated files --- openapi/spec.go | 175 ++++++++++++++++++++++++----------------------- openapi/types.go | 8 +-- 2 files changed, 92 insertions(+), 91 deletions(-) diff --git a/openapi/spec.go b/openapi/spec.go index ea268bf6..05fefae2 100644 --- a/openapi/spec.go +++ b/openapi/spec.go @@ -18,93 +18,94 @@ import ( // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+w9XVMUyZZ/paN2HzS2Efy4D8s+4TAxQcQ4dxx092EkOoruFGq2u6qtqlYJgwiqelSQ", - "Vln8RByRkQGEsXGus15U1P+ySTXw5F/YyKyvzKysrqymm3Fu3AjDoKvyZJ7vPHnyZNYVKa+VypoKVNOQ", - "eq9IZVmXS8AEOv41Khunxvp1+bw5oJ6uAH0MPSwAI68rZVPRVKlXMvUKgFbdefq7MzMJJ6zd62vO5DX0", - "ZP7Jzou70Kpvb0435jehdRNaNefFQ+fxKrTmoD0N7Z9h9RW0f4PVLVidhFYdWh+hPevcvu98eOC1mbDP", - "qeflotHCGA+gtQatH4WHYeCC0VAT6w20lhEo0xmnGykrKYgxFzC/spIql4DUS7BSykpGfhSUZMRMc6yM", - "3g5rWhHIqjQ+nnVbfgeMsqYaoFW+f9qaDHliz+7NLUHr9qetqU7JQGC8z1AePpeTRKIY6YyAUEc+dRF2", - "EhAr0NrwWWXjDvh98PmDYD3+RFjRfaECDISyKis6MLqvUL8H+se7dY8hRsbDY8JyafsArQVobTQerzVq", - "1536owD3jKYWx0JOZqBtZxBIBloubsuNqd+gda2ZLDz2JklB1cy/XgR6fyXeKlxlbDxe2JubgVZtz7oF", - "0b9lxMwIS13+ZA4hdA9nM81gkaVgQNt2ZtahbeHnFJ8zh7BQD8MJG1avw+o9aD+H1XWs4huuvJuwIKQt", - "iQuI332FkqIqhqnLJiicHDsVzxDfTGs79cWdmWu7E1ehtY5Z8UyQLbGAHecJl1IR9gi4z+gUEqWcabP9", - "7hdn6X5HqRX3SKj1GVkfAaaijojIH9ofkY+0/warVYxRjDB5jBCF7SRrCGKTeFOWR+IZsv3+Hqw+wuRs", - "7szXoTWdOdR4vObUH+18eB56ZGvjKNnscAxmaCgeOopqghGgY3QYJzugfiubo1HEmKlsoD9kRxkBBGMy", - "/UlZSQcXKooOClIvElg6dIx4X+pNHw+w6/+xUb3qPP2NO+dieT5D8kSKsobfvvTmHWsBK0QdVv8HVp/D", - "6iLm6Uc4Ye8uXW/ce+lOJ87tjd3qe18HwOVyUSsAqRdrBp/zLBmUFBQTlAwe/Vn/iazr8hjmhz/lxUvG", - "Nf94gYQ9pJWFDzmo6fHBxfbmMrRe7T29ljm0/f5xY3Km8eCXxpwNrVrj/kssnh8z5ySjMlxSTBMUcrJ5", - "TspmmKbO7SW3XRfb8IwuXxjoh1a98fA6GuScZOryBaVAvdubu+m+6wpfNuZ/b9x/yUWmpBWU80owBNMy", - "xIVql4kzMkPT6djgX3VwXuqV/qU7XLt0u2+N7u8Ilp5BHEd8NoCs50djOYz00V5CulxdR4xYerzz+89x", - "yOCueDZvmLqijrjj7V+eeR3IAtKkm7GEBBJVzCLgNCDE6rf4PKVKSHPch8EG/mVBMU+TrgA9lIvFv56X", - "er9v3udpxo+OZ1O0PykbIBEigpw7iRl9agHHNQbuoaxrZaCbCsAE+X7NoPyYCF4x3s13R98TXQ+ND41n", - "pWT0elnsZPQ8CaGzBtBRJ1/pWqVsYLRwx2nhQnq04R9A3pQ8nIMYiRI1jajvWXNKAf0El+VSuQik3qNZ", - "nh9mh2mO5zfgUoACZqSHMCkw2uRxg8zZs94ccl7TS7Ip9UqVilKQsqwDYcWYlb4BlwIpp9ZvIVX1Gw8C", - "EwVZxskx19yG6NH3Y2Kp8OicuZC8TGsxpOQjtjGsFcbSYOH3dBLBjWelkqIOuKBHoxqgGLkCXiOTyuxG", - "GJwImKQhgMy6GA5xrIrFpolh+SzxDIsb1EQZiFoP8exMU4GAHpHInQGXk02UBfhaw4YlDvRNpTSMA0Zx", - "kEFFHSmCL0Y1JQ9SAZ6qFE2l3BLoYF4uYitF2vkHuYiITYYxCWc9+mrNuXcb2rO7H987N54GiaWdv6/u", - "zV9z1wzBEkytFIvhmsBTeulYz7Gerp6jXT1Hz/T09OJ//9bz7709PaRnLcgm6DKVEuC5V0aHEzBMRok3", - "qVASwfyNOAxq3CtRNBUjF9oSf1HifLy693QSWtPQeg6ta9CaxpixLiHrRoD8YJW0V7dZlhqLRoTnPmI0", - "o1fUvFl4IRPnAQmZOQsoaOosWCpzZ4FTmnxkbN/so7xnek7tCpDkONjRmquVo3NuRH3j57OsVFGVCxXg", - "vUbTGKuI/ghRdeMR7YmwJWIJ8dNEluTLuYtysYKZGDoWrTJcJLyK6oMjelO0HxcizBV0S3R5OsIlqygP", - "gyJfaCTR0ewJorEJMMmBhMggbEsOKiZuyvRaYw5rvC3od2dVGvvAlmgLvCdH7kAdcTNcyYugOKSwj20Z", - "scBDtw85jsfqjY1aTW9iAmqlhITCAA5lEyZIuqNmkyHhW8TR8QDajEbgRQSxcNu3GwnGaEVxIcHajJJv", - "Y4Ko4OYdQMG3qBRoYJA2ohIu0Ftb25/BcWOqxX0/EWKmAvTXP/0V0C+b4AwK8lvq4D8VcEkeLoKTY+ng", - "B4w+VVPHSlrFSAvYXykXlbxsgj7VuAT0vmJRuwQKaXv5tjJcVIxRBDjECvELd/HVx1Fsel3WvgUVo3XE", - "MIkq108vgZquj0KEd3799agz/wRvJr2C1RlY3dqbv7a99QgXLdShPQXtO//38Bq0/g7tm9Ba2Zl7u7u4", - "Gmxlbb99C+1ZZ3oBLzkf4EXoXEYIzLKhtYQA7Bqsbn3ashLZQVIhwA9TVoqfUw47RrlSgZ3yNgTSwgnk", - "/+QgUU0vj4mqhfr25sTu8gqcsD9tTTpTN535J87Gh93fFtFbe9bPPCAhN+bsHfsNtFZ2V541nsx4krdf", - "4q3qLVh94G+CrjsLb6H1C66fWHY3Z6B1DykFam9Dq/5pa8rdLxTJBuJ9tgIv89vO7QB/v7HgF/vxMgoM", - "y5zaR2fmZqRmawXa9u7Hu2SN1f7odHcIeFgFovozCzI2r0zLJORD1tfsoaiLH+iPjxZwA7FNDx5CAXii", - "pxpQz2sHFyzse84/YM81YJD1KuPJ3CRCiohsFSMnk28ZA8EW6pcxMYnA1FsF4UACOMdEMzwCCn7TnIzb", - "5uSwMbs9PoEn2ppbH4KM3KNtBdeWTu+fyFhkBGgOYy8emWXybdP6HmjVnKu/7t2f9t2NX0q1b+pCHATI", - "+Q6UFLXwpYriYD5JOm6RA2ETfoXZurPxwav5qa7B6hNcZ/AKVqegVWs8nnJuvCFJw4Vgy7gW6DX636oR", - "jn4FhVn2Ddx+yZf+A7JekSgGvgOtujOxBK2aW6nqAteg9TKKx96Etf1x0eN4OHcl85RhggBjSfvnsdX0", - "3+dKYF9le/vWFwqTRMq+VgzO0qIsj4BcSb7McU8zk7uruMrbL6dr3HuZuH3ClKmlj34w2GClVJL1scTp", - "OMA+MmwiO4gJIsIUog6mowsucpxEhHkzaW9s9UShAnIIkZyp8NSUKpmesF1j9CqC7Fm/gnoB2lOUD0ca", - "fBv9j00Qb6kRhZd0HfYasvy27wGOi7KJyBHEc+mi1yg3PCZQnjY4KusgKE0jBcntMFGivpYfzKLxn5me", - "PyjTs4/w8+ADZWaRXizm/GUOLzIiDvQQUUDNcwSbk9D6GJ43cQ+X2LNo+WnNYa/zE7RrXnW+tZHBRcNU", - "i8whqtuYczNMz4dFZlN8WitXGguLc9hgln/2TWrSlU5UGPHcrUhXKJInT07khseaxxnNTnkQEQZ3sEC0", - "4VQRjtfCfEaxNMqWWOqyjKJxls6fX4UjVX3hduKt/ZM9/xm/qIMmIaj1iGRMvSwpnej08qD08iRRSO4g", - "PBSpKS55GVTf+d+ZxpPH0J7NZvasaef+a2ht7C5PYyxRlJ45dM5LhpyTwmNavlfAa8VIeyKpck46nNld", - "e+nlrNh+1TFNBeckz9i9HRYv9cLmZtzGiOaQsd4zTpERv0o17by8jxJDtr6QU1JIpRUSXV2KGsRs54Jf", - "UirivPUTc0PZJllPU5dPZwb6g7RncDYsmCPw1OBuGMxD+44bmBIHOQQyb1nqqEZHFwYkVoy8GSxoeWUF", - "ikaHCBX/Z83on7ZmlHxxUjbAgCeU6LyIw1gB8XkNY2YGarC48ohwLJEyqhYHH3RtJpnQGOMSHOpA6vFC", - "nNtXrhRLH6s2bSq+E1VfQnEiqLSlXC4NIr65RDFpf4laGrw85Y6g1Yaqsnah0aY6sv2iE5wsi0Sq/uy5", - "c3cVWnVD000UrK7W9xafELEiM412NZ1Wu+if7llK9Nz7K1LRk5Uud6GRui7KuiqXkLV/Lw36Q/SZfYNf", - "SFnyQf+X+Em4HO9jfnsN3Hioj/gbvyA581+KORrZ8kMOg5fkrUHrYTS092J1d6/VnsVx06p7QDxYnOPI", - "u+lmprfTKB46IwAiKDNSh8zCG7gEhuR4zSaFdmBD9MaV0kEk6IPgciR6+k2Eqnj1SpGuZ5HgMT7ewn0p", - "wgmLEqhr8dB+H65RWbMnipyy5ClcKRucoejy/2jiA8QMPsjJufYa/PRtGY3TF/4p5gOQqSPL/2JUVlW3", - "oJvZpCpQEVjcWUX3OC8n4CjLVDVvTBiFO8V98GRHICiuYSRVHNNBr3ECpmWSSwCFH0J4nDWAjgc75cHE", - "cyyWMeGAcSz6Kp0JhhyIYc9AgXfHEV4cMxsx+fKxHh6LUC+DplziMPm8UvTXyYmc3p8ONuGoj0QcRzHu", - "6TjqkhvDUaQHHH3La2quA/xQjNywZvKuRwl5xZVuYkqB5CGFfTBoHEcZS2jZ+HStKCpr3FQQn3SyZolp", - "IvX0HfN6C3pisqlEDQW+qCMokWtHUR6Tro5ILJz5m/UfJskrhoDXdCll5emCZqXYeR5f0+aFPnlNNeU8", - "1n/vsglTl79Fi169KPVKo6ZZNnq7u0cUc7QyfCSvlbrRe1MxQX60W1b/G3SZOICkee29yPR9OxDM8uzT", - "i0A33NYXj6MetDJQ5bIi9UrHj/QcOSG5syLmQXe0wsHbQWC3kG5Ba9LLWlsLjZ8Wt9+9hvZs4+0ELkue", - "O9az/e719rtftjenObfzoLhuDdpv6NvxvJpnOGFLGEldRqMhty99BehLIQyMdHgrZMxCLWzSTd5+ErdE", - "I5tTl7MIAJD3Owk0j7kqSxAy9qo1AfjohXUCQNzrH0XhqGsKx4eYNdCxnh7fPPzkedndpVY0tfsHw62z", - "F7uRJVoUhE2Q1t3Gi2fO5ia01n29c8s/Pnj1uBN2VFv3rt50Jh/4u7CEno5npRMu/s3tw7+mynn/s7N1", - "G1q1nXsvca3JDbcv1NFfeB1F77WKRx8n/9FPRIfb4/Foj4Onv0aI1Bd2F2uNOXvv/h1o1Y4biLjXVxHS", - "1oJfyGZvb76D1nrjxbPd5du7i6s7tz9Aq+bcWnDmn2Lq8R7jiBFZfOI4vqwZHM8R3N0TpcyvdG7iB77V", - "DOZmHdcjA8P08/1tUaTI7SLjtO/HucmIIh/tjCJ75y+aqXI8M1nljpSTs4Cfk4pHiGBUvIn+jWelxJtF", - "XVyKwAQiWDlTN/ANsk3Usx93xipouomKfzNfnNsU1gcfe1YfYuUb9YIolqvt/LiIq2ZX0ojUqvvD11oS", - "ZpYfhUSHCbJ6LcQUHZbUH+IX4qa4/Ur9RM8JoWLysKrJk3cnJrrms5Bs5kfTqc7SVGP+d1J1vPNGvFsm", - "9356EnMaad3vx71x8g6+ejii/hM2ruvodqtsoFVjC0+DGqhu8lgRjaPXU0TDo/fQtVPF2z/rRvEVmnZT", - "OkKPde03ib/EnYPphObEGlIa0ts7nXaXxtyTG4OmbFbiF48tHs5oyamfolH6s7l49izMP4ivp8W9u/rC", - "qT/qtMdvXesi80Gyo+2g2rXf7SZr3H48cOf87cGrZYf9J8VVwYjXubrqFam7dYE8LxlXOBjWLNLrvrh4", - "gnWu4e5xmxQ8OaHEu5xaMGnWUgKL+chGRz1/yM5/DDcfaGSb/HrzfFIwWjSNFKUiOMa78+Kud807woI6", - "xbln3XJuvSO+xlDzF7Fu7kQ4R0V8PuFzngaoG4QPNt1Fjxun+KyAE1JbQftOZrT2ZTUnjh0T/lrMgvvR", - "AvrIovBaIFDhfWbUAg3AsX7SbBV8q4Q6TBV824TYAoITVoRpd/G6Z52szwqqgZybD7ff34TWitiS4NRY", - "61NVyo2duC9ZfK5zDb9Uq6Vtk6BUCxvO9ruH7remvA/VuHvAf0B+mfNVHc7mo4BBBIe9WFu4En5so2lO", - "OQzR6FRyTB655Xkj8vWQlNnjCJ6UqEUlFrDbFx3ffx4X4BNarTVWn7se0b+HgQhUhR1xqAOBg8X3d9Qi", - "DjbOZXOzPCIum8PNpr47FGLLWhqTNm+yUuA5z86r4QHGEGmDZ0IKvJC5mfLu3f8b8ngtK2+CJrY/5KY0", - "JyaLEuqOWFak3crTmaRzurhXzHXy0h4H6TqJHHKc9v0p/ObBuUs0qZu6fKE7TxT3cj2oqcuncRxhweoz", - "nC26CavrQYgj5lepMuIOOkZqnNYCO4rMhHqYmNQan1OtuCkkIlJaYcFfU1kR9+y1ICivXrDDYgqO4LdS", - "tBTS15KEePxph3iMoGi5uXje4FulXrUmHq8yusPi8UZpTTwhfa2Jh8OfdognKH9t7ujCit4WpHPWq5Pt", - "qHD8Et1W/Bt551srzo3DnbbJptutjOdvB6CF7Aa0V73lLE0IEl7KgpiAkafAQchrH7H6ibhbWZkb/HaX", - "p/HlyNNU2E1XSSbINODhfmSKP/ioX/SjX3q0sq4VKnnvwzZ0PbhXyn3E1OXykR/K3XJZwbkkuoMCuAiK", - "WrmEJBPTQ1cBXMS9mMoRt6ac25NcLI/KmUMFUC5qY6CQ0dSMqgFjVLuUlw3wHxk5b1bkYqaiFzOKkcFj", - "HKaGJEbEfbmYow5iRhwGZrsGRF0ljlfU8nKR7QE/HNUMs/fo8WPHXcihQIpXuB94xX0zn1sln2HRjw+N", - "/38AAAD//6NXy0DPgAAA", + "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", + "pj4aSz7Doh8fHv//AAAA//8JZgvC6oEAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/openapi/types.go b/openapi/types.go index ee031746..43e0a4a3 100644 --- a/openapi/types.go +++ b/openapi/types.go @@ -472,7 +472,7 @@ type QuestionnaireList struct { PageMax int `json:"page_max"` Questionnaires []QuestionnaireSummary `json:"questionnaires"` - // TotalRecords 条件に一致するアンケートの総件数 + // TotalRecords 現在の検索条件に一致するアンケートの総件数 TotalRecords int `json:"total_records"` } @@ -723,12 +723,12 @@ type UsersAndGroups struct { Users Users `json:"users"` } -// HasMyDraftInQuery defines model for hasMyDraftInQuery. -type HasMyDraftInQuery = bool - // CountOnlyInQuery defines model for countOnlyInQuery. type CountOnlyInQuery = bool +// HasMyDraftInQuery defines model for hasMyDraftInQuery. +type HasMyDraftInQuery = bool + // HasMyResponseInQuery defines model for hasMyResponseInQuery. type HasMyResponseInQuery = bool From 1ceacf6e0377edab255237ff455cc5ed986a987e Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Sat, 11 Apr 2026 19:38:39 +0900 Subject: [PATCH 4/4] test: clarify reminder subscription behavior --- controller/questionnaire_test.go | 51 ++++++++++++++++++++++++++++++++ controller/reminder.go | 1 + model/reminder_targets.go | 3 +- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/controller/questionnaire_test.go b/controller/questionnaire_test.go index 7f5fc8ee..f3af29af 100644 --- a/controller/questionnaire_test.go +++ b/controller/questionnaire_test.go @@ -2105,6 +2105,57 @@ func TestEditQuestionnaireMyRemindStatus(t *testing.T) { assertion.False(status, "target user can opt out via override") } +func TestEditQuestionnaireMyRemindStatusPersistsExplicitSubscriptionAfterTargetRemoval(t *testing.T) { + t.Parallel() + + assertion := assert.New(t) + + questionnaire := sampleQuestionnaire + questionnaire.Target = openapi.UsersAndGroups{ + Users: []string{userThree, userFour}, + Groups: []uuid.UUID{}, + } + + e := echo.New() + body, err := json.Marshal(questionnaire) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/questionnaires", bytes.NewReader(body)) + rec := httptest.NewRecorder() + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + ctx := e.NewContext(req, rec) + questionnaireDetail, err := q.PostQuestionnaire(ctx, questionnaire) + require.NoError(t, err) + + req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/questionnaires/%d/myRemindStatus", questionnaireDetail.QuestionnaireId), nil) + rec = httptest.NewRecorder() + ctx = e.NewContext(req, rec) + err = q.EditQuestionnaireMyRemindStatus(ctx, questionnaireDetail.QuestionnaireId, userThree, true) + require.NoError(t, err) + + editParams := postQuestionnaireParams2EditQuestionnaireParams(questionnaireDetail.QuestionnaireId, questionnaireDetail.Questions, questionnaire) + editParams.Target = &openapi.UsersAndGroups{ + Users: []string{}, + Groups: []uuid.UUID{}, + } + + req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/questionnaires/%d", questionnaireDetail.QuestionnaireId), nil) + rec = httptest.NewRecorder() + ctx = e.NewContext(req, rec) + err = q.EditQuestionnaire(ctx, questionnaireDetail.QuestionnaireId, editParams) + require.NoError(t, err) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/questionnaires/%d/myRemindStatus", questionnaireDetail.QuestionnaireId), nil) + rec = httptest.NewRecorder() + ctx = e.NewContext(req, rec) + status, err := q.GetQuestionnaireMyRemindStatus(ctx, questionnaireDetail.QuestionnaireId, userThree) + require.NoError(t, err) + assertion.True(status, "explicitly opted-in user remains a reminder target after target removal") + + status, err = q.GetQuestionnaireMyRemindStatus(ctx, questionnaireDetail.QuestionnaireId, userFour) + require.NoError(t, err) + assertion.False(status, "removed target without an explicit reminder subscription is not reminded") +} + func TestGetQuestionnaireResponses(t *testing.T) { t.Parallel() diff --git a/controller/reminder.go b/controller/reminder.go index f35aafa7..0abb941c 100644 --- a/controller/reminder.go +++ b/controller/reminder.go @@ -220,6 +220,7 @@ func reminderAction(questionnaireID int, leftTimeText string) error { for _, target := range questionnaire.Targets { reminderTargetMap[target.UserTraqid] = !target.IsCanceled } + // reminder_targets stores per-user subscriptions independent from questionnaire targets. for _, target := range reminderTargetOverrides { reminderTargetMap[target.UserTraqid] = !target.IsCanceled } diff --git a/model/reminder_targets.go b/model/reminder_targets.go index d53c6c4f..3fa5373e 100644 --- a/model/reminder_targets.go +++ b/model/reminder_targets.go @@ -15,7 +15,8 @@ func NewReminderTarget() *ReminderTarget { } type ReminderTargets struct { - QuestionnaireID int `gorm:"type:int(11) AUTO_INCREMENT;not null;primaryKey"` + // QuestionnaireID + UserTraqid is a composite key for per-user reminder subscriptions. + QuestionnaireID int `gorm:"type:int(11);not null;primaryKey"` UserTraqid string `gorm:"type:varchar(32);size:32;not null;primaryKey"` IsCanceled bool `gorm:"type:tinyint(1);not null;default:0"` }