diff --git a/server/src/main.go b/server/src/main.go index 96869a76..e85a44b9 100644 --- a/server/src/main.go +++ b/server/src/main.go @@ -215,7 +215,8 @@ func (c MainController) corsAllowGetMiddleware(h http.Handler) http.Handler { func router() (*mux.Router, *sqlx.DB) { db := model.NewDB(config.DBDataSource()) userRepo := persistence.NewUserRepository(db) - main := MainController{db: db, userRepo: userRepo} + activistRepo := persistence.NewActivistRepository(db) + main := MainController{db: db, userRepo: userRepo, activistRepo: activistRepo} csrfMiddleware := csrf.Protect( []byte(config.CsrfAuthKey), csrf.Secure(config.IsProd), // disable secure flag in dev @@ -282,6 +283,7 @@ func router() (*mux.Router, *sqlx.DB) { // Authed API router.Handle("/api/csrf-token", csrfMiddleware(alice.New(main.apiAttendanceAuthMiddleware).ThenFunc(main.CSRFTokenHandler))).Methods(http.MethodGet) + router.Handle("/api/activists", alice.New(main.apiOrganizerOrNonSFBayAuthMiddleware).ThenFunc(main.ActivistsSearchHandler)).Methods(http.MethodGet) router.Handle("/activist_names/get", alice.New(main.apiAttendanceAuthMiddleware).ThenFunc(main.AutocompleteActivistsHandler)) router.Handle("/activist_names/get_organizers", alice.New(main.apiAttendanceAuthMiddleware).ThenFunc(main.AutocompleteOrganizersHandler)) router.Handle("/activist_names/get_chaptermembers", alice.New(main.apiAttendanceAuthMiddleware).ThenFunc(main.AutocompleteChapterMembersHandler)) @@ -356,8 +358,9 @@ func router() (*mux.Router, *sqlx.DB) { } type MainController struct { - db *sqlx.DB - userRepo model.UserRepository + db *sqlx.DB + userRepo model.UserRepository + activistRepo model.ActivistRepository } func (c MainController) authRoleMiddleware(h http.Handler, allowedRoles []string) http.Handler { @@ -378,7 +381,7 @@ func (c MainController) authRoleMiddleware(h http.Handler, allowedRoles []string return } - if !userIsAllowed(allowedRoles, user) { + if !model.UserHasAnyRole(allowedRoles, user) { http.Redirect(w, r.WithContext(setUserContext(r, user)), "/403", http.StatusFound) return } @@ -404,18 +407,6 @@ func (c MainController) authAdminMiddleware(h http.Handler) http.Handler { return c.authRoleMiddleware(h, []string{"admin"}) } -func userIsAllowed(roles []string, user model.ADBUser) bool { - for i := 0; i < len(roles); i++ { - for _, r := range user.Roles { - if r == roles[i] { - return true - } - } - } - - return false -} - func getUserMainRole(user model.ADBUser) string { if len(user.Roles) == 0 { return "" @@ -454,7 +445,7 @@ func (c MainController) apiRoleMiddleware(h http.Handler, allowedRoles []string) return } - if !userIsAllowed(allowedRoles, user) { + if !model.UserHasAnyRole(allowedRoles, user) { http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) return } @@ -1692,6 +1683,15 @@ func (c MainController) CSRFTokenHandler(w http.ResponseWriter, r *http.Request) }) } +func (c MainController) ActivistsSearchHandler(w http.ResponseWriter, r *http.Request) { + authedUser, authed := c.getAuthedADBUser(r) + if !authed { + panic("ActivistsSearchHandler requires authed ADB user") + } + + transport.ActivistsSearchHandler(w, r, authedUser, c.activistRepo) +} + func (c MainController) UsersListHandler(w http.ResponseWriter, r *http.Request) { transport.UsersListHandler(w, r, c.userRepo) } diff --git a/server/src/model/activist.go b/server/src/model/activist.go index 1c5e7cb7..0b6ab2c4 100644 --- a/server/src/model/activist.go +++ b/server/src/model/activist.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log" + "slices" "regexp" "strconv" @@ -388,9 +389,10 @@ type ActivistEventData struct { } type ActivistMembershipData struct { - ActivistLevel string `db:"activist_level"` - Source string `db:"source"` - Hiatus bool `db:"hiatus"` + ActivistLevel string `db:"activist_level"` + DateOrganizer sql.NullTime `db:"date_organizer"` + Source string `db:"source"` + Hiatus bool `db:"hiatus"` } type ActivistConnectionData struct { @@ -445,72 +447,72 @@ type ActivistExtra struct { } type ActivistJSON struct { - Email string `json:"email"` - Facebook string `json:"facebook"` - ID int `json:"id"` - Location string `json:"location"` - Name string `json:"name"` - PreferredName string `json:"preferred_name"` - Phone string `json:"phone"` - Pronouns string `json:"pronouns"` - Language string `json:"language"` - Accessibility string `json:"accessibility"` - Birthday string `json:"dob"` - ChapterID int `json:"chapter_id"` - - FirstEvent string `json:"first_event"` - LastEvent string `json:"last_event"` - FirstEventName string `json:"first_event_name"` - LastEventName string `json:"last_event_name"` - LastAction string `json:"last_action"` - MonthsSinceLastAction int `json:"months_since_last_action"` - TotalEvents int `json:"total_events"` - TotalPoints int `json:"total_points"` - Active bool `json:"active"` - Status string `json:"status"` - - ActivistLevel string `json:"activist_level"` - Source string `json:"source"` - Hiatus bool `json:"hiatus"` - - Connector string `json:"connector"` - Training0 string `json:"training0"` - Training1 string `json:"training1"` - Training4 string `json:"training4"` - Training5 string `json:"training5"` - Training6 string `json:"training6"` - ConsentQuiz string `json:"consent_quiz"` - TrainingProtest string `json:"training_protest"` - ApplicationDate string `json:"dev_application_date"` - ApplicationType string `json:"dev_application_type"` - Quiz string `json:"dev_quiz"` - DevInterest string `json:"dev_interest"` - - CMFirstEmail string `json:"cm_first_email"` - CMApprovalEmail string `json:"cm_approval_email"` - ProspectOrganizer bool `json:"prospect_organizer"` - ProspectChapterMember bool `json:"prospect_chapter_member"` - LastConnection string `json:"last_connection"` - ReferralFriends string `json:"referral_friends"` - ReferralApply string `json:"referral_apply"` - ReferralOutlet string `json:"referral_outlet"` - InterestDate string `json:"interest_date"` - MPI bool `json:"mpi"` - Notes string `json:"notes"` - VisionWall string `json:"vision_wall"` - MPPRequirements string `json:"mpp_requirements"` - VotingAgreement bool `json:"voting_agreement"` - StreetAddress string `json:"street_address"` - City string `json:"city"` - State string `json:"state"` - GeoCircles string `json:"geo_circles"` - Lat float64 `json:"lat"` - Lng float64 `json:"lng"` - AssignedTo int `json:"assigned_to"` - AssignedToName string `json:"assigned_to_name"` - FollowupDate string `json:"followup_date"` - TotalInteractions int `json:"total_interactions"` - LastInteractionDate string `json:"last_interaction_date"` + Email string `json:"email,omitempty"` + Facebook string `json:"facebook,omitempty"` + ID int `json:"id,omitempty"` + Location string `json:"location,omitempty"` + Name string `json:"name,omitempty"` + PreferredName string `json:"preferred_name,omitempty"` + Phone string `json:"phone,omitempty"` + Pronouns string `json:"pronouns,omitempty"` + Language string `json:"language,omitempty"` + Accessibility string `json:"accessibility,omitempty"` + Birthday string `json:"dob,omitempty"` + ChapterID int `json:"chapter_id,omitempty"` + + FirstEvent string `json:"first_event,omitempty"` + LastEvent string `json:"last_event,omitempty"` + FirstEventName string `json:"first_event_name,omitempty"` + LastEventName string `json:"last_event_name,omitempty"` + LastAction string `json:"last_action,omitempty"` + MonthsSinceLastAction int `json:"months_since_last_action,omitempty"` + TotalEvents int `json:"total_events,omitempty"` + TotalPoints int `json:"total_points,omitempty"` + Active bool `json:"active,omitempty"` + Status string `json:"status,omitempty"` + + ActivistLevel string `json:"activist_level,omitempty"` + Source string `json:"source,omitempty"` + Hiatus bool `json:"hiatus,omitempty"` + + Connector string `json:"connector,omitempty"` + Training0 string `json:"training0,omitempty"` + Training1 string `json:"training1,omitempty"` + Training4 string `json:"training4,omitempty"` + Training5 string `json:"training5,omitempty"` + Training6 string `json:"training6,omitempty"` + ConsentQuiz string `json:"consent_quiz,omitempty"` + TrainingProtest string `json:"training_protest,omitempty"` + ApplicationDate string `json:"dev_application_date,omitempty"` + ApplicationType string `json:"dev_application_type,omitempty"` + Quiz string `json:"dev_quiz,omitempty"` + DevInterest string `json:"dev_interest,omitempty"` + + CMFirstEmail string `json:"cm_first_email,omitempty"` + CMApprovalEmail string `json:"cm_approval_email,omitempty"` + ProspectOrganizer bool `json:"prospect_organizer,omitempty"` + ProspectChapterMember bool `json:"prospect_chapter_member,omitempty"` + LastConnection string `json:"last_connection,omitempty"` + ReferralFriends string `json:"referral_friends,omitempty"` + ReferralApply string `json:"referral_apply,omitempty"` + ReferralOutlet string `json:"referral_outlet,omitempty"` + InterestDate string `json:"interest_date,omitempty"` + MPI bool `json:"mpi,omitempty"` + Notes string `json:"notes,omitempty"` + VisionWall string `json:"vision_wall,omitempty"` + MPPRequirements string `json:"mpp_requirements,omitempty"` + VotingAgreement bool `json:"voting_agreement,omitempty"` + StreetAddress string `json:"street_address,omitempty"` + City string `json:"city,omitempty"` + State string `json:"state,omitempty"` + GeoCircles string `json:"geo_circles,omitempty"` + Lat float64 `json:"lat,omitempty"` + Lng float64 `json:"lng,omitempty"` + AssignedTo int `json:"assigned_to,omitempty"` + AssignedToName string `json:"assigned_to_name,omitempty"` + FollowupDate string `json:"followup_date,omitempty"` + TotalInteractions int `json:"total_interactions,omitempty"` + LastInteractionDate string `json:"last_interaction_date,omitempty"` } type GetActivistOptions struct { @@ -575,7 +577,7 @@ func getActivistsJSON(db *sqlx.DB, options GetActivistOptions) ([]ActivistJSON, if err != nil { return nil, err } - return buildActivistJSONArray(activists), nil + return BuildActivistJSONArray(activists), nil } func GetActivistRangeJSON(db *sqlx.DB, options ActivistRangeOptionsJSON) ([]ActivistJSON, error) { @@ -583,10 +585,11 @@ func GetActivistRangeJSON(db *sqlx.DB, options ActivistRangeOptionsJSON) ([]Acti if err != nil { return nil, err } - return buildActivistJSONArray(activists), nil + return BuildActivistJSONArray(activists), nil } -func buildActivistJSONArray(activists []ActivistExtra) []ActivistJSON { +// TODO: move to transport layer and make private once obsolete activist query options are removed. +func BuildActivistJSONArray(activists []ActivistExtra) []ActivistJSON { var activistsJSON []ActivistJSON for _, a := range activists { @@ -2226,3 +2229,89 @@ func assignActivistToUser(db *sqlx.DB, activistID, userID int) error { } return nil } + +func QueryActivists(authedUser ADBUser, options QueryActivistOptions, repo ActivistRepository) (QueryActivistResult, error) { + if !UserHasRole("admin", authedUser) { + if authedUser.ChapterID != options.Filters.ChapterId || authedUser.ChapterID == 0 { + return QueryActivistResult{}, fmt.Errorf("Cannot query activists in other chapters without admin access") + } + } + + if !UserHasAnyRole([]string{"admin", "organizer", "non-sfbay"}, authedUser) { + return QueryActivistResult{}, fmt.Errorf("Lacking permission to query activists") + } + + options.normalizeAndValidate() + + return repo.QueryActivists(options) +} + +// Interface for querying and updating activists. This avoids a dependency on the persistence package which could create +// a cyclical package reference. +type ActivistRepository interface { + QueryActivists(options QueryActivistOptions) (QueryActivistResult, error) +} + +type ActivistColumnName string + +type QueryActivistOptions struct { + // This model is currently shared with the transport layer and treated as part of the frontend API. + // Introduce transport DTOs when the wire format needs to differ from internal semantics. + + Columns []ActivistColumnName `json:"columns"` + Filters QueryActivistFilters `json:"filters"` + Sort ActivistSortOptions `json:"sort"` + + // Cursor pointing to last item in previous page (base 64 encoding of values of sort columns and ID). + // Must be a value returned by QueryActivistResultPagination.NextCursor. + // If empty, the first page of results will be returned. + // If invalid, an error is returned. + After string `json:"after"` +} + +type QueryActivistFilters struct { + // 0 means search all chapters. Requires that the "chapter" column be requested. + // Must be set to ID of current chapter if user only has permission for current chapter. + ChapterId int `json:"chapter_id"` + Name ActivistNameFilter `json:"name"` + LastEvent LastEventFilter `json:"last_event"` + IncludeHidden bool `json:"include_hidden"` +} + +type ActivistNameFilter struct { + Name string `json:"name"` +} + +type LastEventFilter struct { + LastEventLt *time.Time `json:"last_event_lt"` + LastEventGt *time.Time `json:"last_event_gt"` +} + +type ActivistSortOptions struct { + SortColumns []ActivistSortColumn `json:"sort_columns"` +} + +type ActivistSortColumn struct { + ColumnName ActivistColumnName `json:"column_name"` + Desc bool `json:"desc"` +} + +type QueryActivistResult struct { + Activists []ActivistExtra `json:"activists"` + Pagination QueryActivistResultPagination `json:"pagination"` +} + +type QueryActivistResultPagination struct { + // An opaque string if more results are available; otherwise, the empty string. + NextCursor string `json:"next_cursor"` +} + +func (o *QueryActivistOptions) normalizeAndValidate() error { + // TODO: remove invalid characters from o.nameFilter.name + + if o.Filters.ChapterId == 0 && !slices.Contains(o.Columns, "chapter") { + return fmt.Errorf("Must choose 'chapter' column when not filtering by chapter ID.") + } + + return nil +} diff --git a/server/src/model/adb_auth.go b/server/src/model/adb_auth.go index 825d570e..744786e1 100644 --- a/server/src/model/adb_auth.go +++ b/server/src/model/adb_auth.go @@ -54,6 +54,26 @@ func ValidateADBUser(user ADBUser) error { return nil } +func UserHasAnyRole(roles []string, user ADBUser) bool { + for i := 0; i < len(roles); i++ { + if UserHasRole(roles[i], user) { + return true + } + } + + return false +} + +func UserHasRole(role string, user ADBUser) bool { + for _, r := range user.Roles { + if r == role { + return true + } + } + + return false +} + // Interface for querying and updating users. This avoids a dependency on the persistence package which could create a // cyclical package reference. type UserRepository interface { diff --git a/server/src/persistence/activist_columns.go b/server/src/persistence/activist_columns.go new file mode 100644 index 00000000..0cfe85a5 --- /dev/null +++ b/server/src/persistence/activist_columns.go @@ -0,0 +1,117 @@ +package persistence + +import ( + "fmt" + + "github.com/dxe/adb/model" +) + +// activistColumn defines how to select a column, including any joins it requires. +type activistColumn struct { + // sql is the SQL expression for this column in the SELECT clause. + sql string + joins []joinSpec +} + +var simpleColumns = map[model.ActivistColumnName]string{ + "id": fmt.Sprintf("%s.id", activistTableAlias), + "name": fmt.Sprintf("%s.name", activistTableAlias), + "preferred_name": fmt.Sprintf("%s.preferred_name", activistTableAlias), + "email": fmt.Sprintf("LOWER(%s.email) as email", activistTableAlias), + "phone": fmt.Sprintf("%s.phone", activistTableAlias), + "pronouns": fmt.Sprintf("%s.pronouns", activistTableAlias), + "language": fmt.Sprintf("%s.language", activistTableAlias), + "accessibility": fmt.Sprintf("%s.accessibility", activistTableAlias), + "dob": fmt.Sprintf("%s.dob", activistTableAlias), + "facebook": fmt.Sprintf("%s.facebook", activistTableAlias), + "location": fmt.Sprintf("%s.location", activistTableAlias), + "street_address": fmt.Sprintf("%s.street_address", activistTableAlias), + "city": fmt.Sprintf("%s.city", activistTableAlias), + "state": fmt.Sprintf("%s.state", activistTableAlias), + "lat": fmt.Sprintf("%s.lat", activistTableAlias), + "lng": fmt.Sprintf("%s.lng", activistTableAlias), + "chapter_id": fmt.Sprintf("%s.chapter_id", activistTableAlias), + "activist_level": fmt.Sprintf("%s.activist_level", activistTableAlias), + "source": fmt.Sprintf("%s.source", activistTableAlias), + "hiatus": fmt.Sprintf("%s.hiatus", activistTableAlias), + "connector": fmt.Sprintf("%s.connector", activistTableAlias), + "training0": fmt.Sprintf("%s.training0", activistTableAlias), + "training1": fmt.Sprintf("%s.training1", activistTableAlias), + "training4": fmt.Sprintf("%s.training4", activistTableAlias), + "training5": fmt.Sprintf("%s.training5", activistTableAlias), + "training6": fmt.Sprintf("%s.training6", activistTableAlias), + "consent_quiz": fmt.Sprintf("%s.consent_quiz", activistTableAlias), + "training_protest": fmt.Sprintf("%s.training_protest", activistTableAlias), + "dev_application_date": fmt.Sprintf("%s.dev_application_date", activistTableAlias), + "dev_application_type": fmt.Sprintf("%s.dev_application_type", activistTableAlias), + "dev_quiz": fmt.Sprintf("%s.dev_quiz", activistTableAlias), + "dev_interest": fmt.Sprintf("%s.dev_interest", activistTableAlias), + "cm_first_email": fmt.Sprintf("%s.cm_first_email", activistTableAlias), + "cm_approval_email": fmt.Sprintf("%s.cm_approval_email", activistTableAlias), + "prospect_organizer": fmt.Sprintf("%s.prospect_organizer", activistTableAlias), + "prospect_chapter_member": fmt.Sprintf("%s.prospect_chapter_member", activistTableAlias), + "referral_friends": fmt.Sprintf("%s.referral_friends", activistTableAlias), + "referral_apply": fmt.Sprintf("%s.referral_apply", activistTableAlias), + "referral_outlet": fmt.Sprintf("%s.referral_outlet", activistTableAlias), + "interest_date": fmt.Sprintf("%s.interest_date", activistTableAlias), + "mpi": fmt.Sprintf("%s.mpi", activistTableAlias), + "notes": fmt.Sprintf("%s.notes", activistTableAlias), + "vision_wall": fmt.Sprintf("%s.vision_wall", activistTableAlias), + "mpp_requirements": fmt.Sprintf("%s.mpp_requirements", activistTableAlias), + "voting_agreement": fmt.Sprintf("%s.voting_agreement", activistTableAlias), + "assigned_to": fmt.Sprintf("%s.assigned_to", activistTableAlias), + "followup_date": fmt.Sprintf("DATE_FORMAT(%s.followup_date, '%%Y-%%m-%%d') as followup_date", activistTableAlias), +} + +func getColumnSpec(colName model.ActivistColumnName) *activistColumn { + if sql, ok := simpleColumns[colName]; ok { + return &activistColumn{sql: sql} + } + + switch colName { + case "chapter": + return &activistColumn{ + joins: []joinSpec{chapterJoin}, + sql: fmt.Sprintf("%s.name as chapter", chapterJoin.Key), + } + case "first_event_date": + return &activistColumn{ + joins: []joinSpec{firstEventSubqueryJoin}, + sql: fmt.Sprintf("%s.first_event_date as first_event", firstEventSubqueryJoin.Key), + } + case "first_event_name": + return &activistColumn{ + joins: []joinSpec{firstEventSubqueryJoin}, + sql: fmt.Sprintf("COALESCE(%s.event_name, 'n/a') as first_event_name", firstEventSubqueryJoin.Key), + } + case "last_event_date": + return &activistColumn{ + joins: []joinSpec{lastEventSubqueryJoin}, + sql: fmt.Sprintf("%s.last_event_date as last_event", lastEventSubqueryJoin.Key), + } + case "last_event_name": + return &activistColumn{ + joins: []joinSpec{lastEventSubqueryJoin}, + sql: fmt.Sprintf("COALESCE(%s.event_name, 'n/a') as last_event_name", lastEventSubqueryJoin.Key), + } + case "total_events": + return &activistColumn{ + joins: []joinSpec{totalEventsSubqueryJoin}, + sql: fmt.Sprintf("COALESCE(%s.event_count, 0) as total_events", totalEventsSubqueryJoin.Key), + } + } + + // TODO: Implement these columns with proper joins: + // - last_action + // - months_since_last_action + // - total_points + // - active + // - status + // - last_connection + // - geo_circles + // - assigned_to_name + // - total_interactions + // - last_interaction_date + + return nil +} diff --git a/server/src/persistence/activist_filters.go b/server/src/persistence/activist_filters.go new file mode 100644 index 00000000..d45a0db6 --- /dev/null +++ b/server/src/persistence/activist_filters.go @@ -0,0 +1,92 @@ +package persistence + +import ( + "fmt" + "time" +) + +type filter interface { + buildWhere() []queryClause + getJoins() []joinSpec +} + +// chapterFilter filters activists by chapter. +type chapterFilter struct { + ChapterId int +} + +func (f *chapterFilter) getJoins() []joinSpec { + return nil +} + +func (f *chapterFilter) buildWhere() []queryClause { + if f.ChapterId == 0 { + return nil + } + return []queryClause{{ + sql: fmt.Sprintf("%s.chapter_id = ?", activistTableAlias), + args: []any{f.ChapterId}, + }} +} + +// nameFilter filters activists by name using LIKE. +type nameFilter struct { + Name string +} + +func (f *nameFilter) getJoins() []joinSpec { + return nil +} + +func (f *nameFilter) buildWhere() []queryClause { + if f.Name == "" { + return nil + } + return []queryClause{{ + sql: fmt.Sprintf("%s.name LIKE ?", activistTableAlias), + args: []any{"%" + f.Name + "%"}, + }} +} + +// hiddenFilter includes or excludes hidden activists. +type hiddenFilter struct{} + +func (f *hiddenFilter) getJoins() []joinSpec { + return nil +} + +func (f *hiddenFilter) buildWhere() []queryClause { + return []queryClause{{ + sql: fmt.Sprintf("%s.hidden = false", activistTableAlias), + args: nil, + }} +} + +// lastEventFilter filters activists by their last event date. +type lastEventFilter struct { + After *time.Time + Before *time.Time +} + +func (f *lastEventFilter) getJoins() []joinSpec { + return []joinSpec{lastEventSubqueryJoin} +} + +func (f *lastEventFilter) buildWhere() []queryClause { + var clauses []queryClause + + if f.After != nil { + clauses = append(clauses, queryClause{ + sql: fmt.Sprintf("%s.last_event_date > ?", lastEventSubqueryJoin.Key), + args: []any{f.After}, + }) + } + if f.Before != nil { + clauses = append(clauses, queryClause{ + sql: fmt.Sprintf("%s.last_event_date < ?", lastEventSubqueryJoin.Key), + args: []any{f.Before}, + }) + } + + return clauses +} diff --git a/server/src/persistence/activist_joins.go b/server/src/persistence/activist_joins.go new file mode 100644 index 00000000..67893d53 --- /dev/null +++ b/server/src/persistence/activist_joins.go @@ -0,0 +1,97 @@ +package persistence + +import "fmt" + +// joinSpec represents a SQL join clause +type joinSpec struct { + // Key is a SQL correlation name (alias of joined table in the query) as well as unique identifier to avoid + // performing the same join twice. + Key string + SQL string +} + +// joinRegistry manages a collection of joins, ensuring each join is only added once. +type joinRegistry struct { + joins map[string]string +} + +func newJoinRegistry() *joinRegistry { + return &joinRegistry{ + joins: make(map[string]string), + } +} + +func (r *joinRegistry) registerJoin(spec joinSpec) { + if _, exists := r.joins[spec.Key]; !exists { + r.joins[spec.Key] = spec.SQL + } +} + +func (r *joinRegistry) getJoins() []string { + joins := make([]string, 0, len(r.joins)) + for _, sql := range r.joins { + joins = append(joins, sql) + } + return joins +} + +const ( + firstEventSubqueryKey = "first_event_subquery" + lastEventSubqueryKey = "last_event_subquery" + totalEventsSubqueryKey = "total_events_subquery" + chapterKey = "chapter" +) + +var ( + firstEventSubqueryJoin = joinSpec{ + Key: firstEventSubqueryKey, + SQL: fmt.Sprintf(` +LEFT JOIN ( + SELECT activist_id, first_event_date, event_name + FROM ( + SELECT + ea.activist_id, + e.date as first_event_date, + e.name as event_name, + ROW_NUMBER() OVER (PARTITION BY ea.activist_id ORDER BY e.date ASC) as rn + FROM event_attendance ea + JOIN events e ON e.id = ea.event_id + ) ranked + WHERE rn = 1 +) %s ON %s.activist_id = %s.id`, firstEventSubqueryKey, firstEventSubqueryKey, activistTableAlias), + } + + lastEventSubqueryJoin = joinSpec{ + Key: lastEventSubqueryKey, + // Note: a more efficient query could be used when only last_event_date is needed. + SQL: fmt.Sprintf(` +LEFT JOIN ( + SELECT activist_id, last_event_date, event_name + FROM ( + SELECT + ea.activist_id, + e.date as last_event_date, + e.name as event_name, + ROW_NUMBER() OVER (PARTITION BY ea.activist_id ORDER BY e.date DESC) as rn + FROM event_attendance ea + JOIN events e ON e.id = ea.event_id + ) ranked + WHERE rn = 1 +) %s ON %s.activist_id = %s.id`, lastEventSubqueryKey, lastEventSubqueryKey, activistTableAlias), + } + + totalEventsSubqueryJoin = joinSpec{ + Key: totalEventsSubqueryKey, + SQL: fmt.Sprintf(` +LEFT JOIN ( + SELECT ea.activist_id, COUNT(DISTINCT ea.event_id) as event_count + FROM event_attendance ea + GROUP BY ea.activist_id +) %s ON %s.activist_id = %s.id`, totalEventsSubqueryKey, totalEventsSubqueryKey, activistTableAlias), + } + + chapterJoin = joinSpec{ + Key: chapterKey, + SQL: fmt.Sprintf("LEFT JOIN fb_pages %s ON %s.chapter_id = %s.chapter_id", chapterKey, chapterKey, activistTableAlias), + } +) diff --git a/server/src/persistence/activists.go b/server/src/persistence/activists.go new file mode 100644 index 00000000..5c983096 --- /dev/null +++ b/server/src/persistence/activists.go @@ -0,0 +1,130 @@ +package persistence + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "slices" + + "github.com/dxe/adb/model" + "github.com/jmoiron/sqlx" +) + +type DBActivistRepository struct { + db *sqlx.DB +} + +func NewActivistRepository(db *sqlx.DB) *DBActivistRepository { + return &DBActivistRepository{db: db} +} + +const activistTableAlias = "a" + +type activistPaginationCursor struct { + // values of the last row of the previous page corresponding to the sort columns. + // Required for this cursor pagination implementation. + SortOffsetValues []any `json:"sort_values"` + + // ID of the activist in the last row of the previous page. + IdOffset int `json:"activist_id"` +} + +func (r DBActivistRepository) QueryActivists(options model.QueryActivistOptions) (model.QueryActivistResult, error) { + var cursor activistPaginationCursor + if len(options.After) > 0 { + decoded, err := base64.StdEncoding.DecodeString(options.After) + if err != nil { + return model.QueryActivistResult{}, fmt.Errorf("invalid pagination cursor: %w", err) + } + if err := json.Unmarshal(decoded, &cursor); err != nil { + return model.QueryActivistResult{}, fmt.Errorf("invalid pagination cursor: %w", err) + } + } + // TODO: use cursor value + _ = cursor + + query := NewSqlQueryBuilder() + query.From(fmt.Sprintf("FROM activists %s", activistTableAlias)) + + // Convert options to filters and columns + filters := buildFiltersFromOptions(options) + + // Ensure chapter_id is in columns if not filtering by chapter + columns := options.Columns + if options.Filters.ChapterId == 0 && !slices.Contains(columns, "chapter_id") { + columns = append(columns, "chapter_id") + } + + registry := newJoinRegistry() + + columnSpecs := []*activistColumn{} + for _, colName := range columns { + colSpec := getColumnSpec(colName) + if colSpec == nil { + return model.QueryActivistResult{}, fmt.Errorf("invalid column name: '%v'", colName) + } + columnSpecs = append(columnSpecs, colSpec) + query.SelectColumn(colSpec.sql) + for _, joinSpec := range colSpec.joins { + registry.registerJoin(joinSpec) + } + } + + for _, filter := range filters { + for _, whereClause := range filter.buildWhere() { + query.Where(whereClause.sql, whereClause.args...) + } + for _, joinSpec := range filter.getJoins() { + registry.registerJoin(joinSpec) + } + } + + for _, joinSQL := range registry.getJoins() { + query.Join(joinSQL) + } + + // TODO: Apply sort options from options.Sort + // TODO: Increase pagination limit for prod + limit := 20 + query.Limit(limit) + + sqlStr, args := query.ToSQL() + + var activists []model.ActivistExtra + if err := r.db.Select(&activists, sqlStr, args...); err != nil { + return model.QueryActivistResult{}, fmt.Errorf("querying activists: %w", err) + } + + return model.QueryActivistResult{ + Activists: activists, + Pagination: model.QueryActivistResultPagination{ + // TODO: set NextCursor if there are more results + NextCursor: "", + }, + }, nil +} + +func buildFiltersFromOptions(options model.QueryActivistOptions) []filter { + var filters []filter + + if options.Filters.ChapterId != 0 { + filters = append(filters, &chapterFilter{ChapterId: options.Filters.ChapterId}) + } + + if options.Filters.Name.Name != "" { + filters = append(filters, &nameFilter{Name: options.Filters.Name.Name}) + } + + if options.Filters.LastEvent.LastEventLt != nil || options.Filters.LastEvent.LastEventGt != nil { + filters = append(filters, &lastEventFilter{ + After: options.Filters.LastEvent.LastEventGt, + Before: options.Filters.LastEvent.LastEventLt, + }) + } + + if !options.Filters.IncludeHidden { + filters = append(filters, &hiddenFilter{}) + } + + return filters +} diff --git a/server/src/persistence/query_builder.go b/server/src/persistence/query_builder.go new file mode 100644 index 00000000..bbce8c7f --- /dev/null +++ b/server/src/persistence/query_builder.go @@ -0,0 +1,107 @@ +package persistence + +import ( + "strconv" + "strings" +) + +type sqlQueryBuilder struct { + base string + columns []string + joins []queryClause + filters []queryClause + orderBy []string + limit *int +} + +type queryClause struct { + sql string + args []any +} + +func NewSqlQueryBuilder() *sqlQueryBuilder { + return &sqlQueryBuilder{base: "FROM activists"} +} + +func (b *sqlQueryBuilder) SelectColumn(column string) *sqlQueryBuilder { + b.columns = append(b.columns, column) + return b +} + +func (b *sqlQueryBuilder) From(base string) *sqlQueryBuilder { + if strings.TrimSpace(base) != "" { + b.base = base + } + return b +} + +func (b *sqlQueryBuilder) Join(clause string, args ...any) *sqlQueryBuilder { + if strings.TrimSpace(clause) != "" { + b.joins = append(b.joins, queryClause{sql: clause, args: args}) + } + return b +} + +func (b *sqlQueryBuilder) Where(clause string, args ...any) *sqlQueryBuilder { + if strings.TrimSpace(clause) != "" { + b.filters = append(b.filters, queryClause{sql: clause, args: args}) + } + return b +} + +func (b *sqlQueryBuilder) OrderBy(order ...string) *sqlQueryBuilder { + b.orderBy = append(b.orderBy, order...) + return b +} + +func (b *sqlQueryBuilder) Limit(limit int) *sqlQueryBuilder { + if limit < 0 { + b.limit = nil + return b + } + b.limit = &limit + return b +} + +func (b *sqlQueryBuilder) ToSQL() (string, []any) { + columns := "*" + if len(b.columns) > 0 { + columns = strings.Join(b.columns, ", ") + } + + var builder strings.Builder + builder.WriteString("SELECT ") + builder.WriteString(columns) + builder.WriteString(" ") + builder.WriteString(b.base) + + args := make([]any, 0) + + for _, join := range b.joins { + builder.WriteString(" ") + builder.WriteString(join.sql) + args = append(args, join.args...) + } + + if len(b.filters) > 0 { + builder.WriteString(" WHERE ") + parts := make([]string, 0, len(b.filters)) + for _, filter := range b.filters { + parts = append(parts, filter.sql) + args = append(args, filter.args...) + } + builder.WriteString(strings.Join(parts, " AND ")) + } + + if len(b.orderBy) > 0 { + builder.WriteString(" ORDER BY ") + builder.WriteString(strings.Join(b.orderBy, ", ")) + } + + if b.limit != nil { + builder.WriteString(" LIMIT ") + builder.WriteString(strconv.Itoa(*b.limit)) + } + + return builder.String(), args +} diff --git a/server/src/transport/activists.go b/server/src/transport/activists.go new file mode 100644 index 00000000..44728426 --- /dev/null +++ b/server/src/transport/activists.go @@ -0,0 +1,39 @@ +package transport + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/dxe/adb/model" +) + +type QueryActivistResultJSON struct { + Activists []model.ActivistJSON `json:"activists"` + Pagination QueryActivistPagination `json:"pagination"` +} + +type QueryActivistPagination struct { + NextCursor string `json:"next_cursor"` +} + +func ActivistsSearchHandler(w http.ResponseWriter, r *http.Request, authedUser model.ADBUser, repo model.ActivistRepository) { + var options model.QueryActivistOptions + if err := json.NewDecoder(r.Body).Decode(&options); err != nil && err != io.EOF { + sendErrorMessage(w, http.StatusBadRequest, err) + return + } + + result, err := model.QueryActivists(authedUser, options, repo) + if err != nil { + sendErrorMessage(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, QueryActivistResultJSON{ + Activists: model.BuildActivistJSONArray(result.Activists), + Pagination: QueryActivistPagination{ + NextCursor: result.Pagination.NextCursor, + }, + }) +}