diff --git a/backend/src/models/coordination_team.go b/backend/src/models/coordination_team.go new file mode 100644 index 00000000..546aeeb9 --- /dev/null +++ b/backend/src/models/coordination_team.go @@ -0,0 +1,37 @@ +package models + +import ( + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// CoordinationTeam represents a dedicated coordination team separate from normal teams. +// It maps a set of coordinator members to the IDs of teams they coordinate. +type CoordinationTeam struct { + ID primitive.ObjectID `json:"id" bson:"_id"` + Name string `json:"name" bson:"name"` + + // Coordinator is the single member that acts as coordinator for this coordination team. + Coordinator *TeamMember `json:"coordinator,omitempty" bson:"coordinator,omitempty"` + + // CoordinatedMembers holds IDs of members (from models.Member) that belong to + // this coordination team for the current event. + CoordinatedMembers []primitive.ObjectID `json:"coordinatedMembers" bson:"coordinatedMembers"` +} + +// HasCoordinator returns true if the given member is a coordinator in this coordination team. +func (ct *CoordinationTeam) HasCoordinator(member primitive.ObjectID) bool { + if ct.Coordinator == nil { + return false + } + return ct.Coordinator.Member == member +} + +// HasCoordinatedTeam returns true if this coordination team coordinates the provided team id. +func (ct *CoordinationTeam) HasCoordinatedMember(memberID primitive.ObjectID) bool { + for _, m := range ct.CoordinatedMembers { + if m == memberID { + return true + } + } + return false +} diff --git a/backend/src/mongodb/company.go b/backend/src/mongodb/company.go index 94b801d3..8a792eb0 100644 --- a/backend/src/mongodb/company.go +++ b/backend/src/mongodb/company.go @@ -284,6 +284,42 @@ func (c *CompaniesType) GetCompanies(compOptions GetCompaniesOptions) ([]*models return companies, nil } +// GetCompaniesByMembers returns companies that have a participation with any of the provided member IDs. +// If eventID is provided, only participations for that event are considered. +func (c *CompaniesType) GetCompaniesByMembers(memberIDs []primitive.ObjectID, eventID *int) ([]*models.Company, error) { + ctx := context.Background() + var companies = make([]*models.Company, 0) + + filter := bson.M{} + + if eventID != nil { + filter["participations"] = bson.M{"$elemMatch": bson.M{"member": bson.M{"$in": memberIDs}, "event": *eventID}} + } else { + filter["participations.member"] = bson.M{"$in": memberIDs} + } + + cur, err := c.Collection.Find(ctx, filter) + if err != nil { + return nil, err + } + + for cur.Next(ctx) { + var comp models.Company + if err := cur.Decode(&comp); err != nil { + return nil, err + } + companies = append(companies, &comp) + } + + if err := cur.Err(); err != nil { + return nil, err + } + + cur.Close(ctx) + + return companies, nil +} + // Transforms a models.Company into a models.CompanyPublic. If eventID != nil, returns only the participation for that event, if announced. // Otherwise, returns all participations in which they were announced func companyToPublic(company models.Company, eventID *int) (*models.CompanyPublic, error) { diff --git a/backend/src/mongodb/coordination_team.go b/backend/src/mongodb/coordination_team.go new file mode 100644 index 00000000..c04e1ffd --- /dev/null +++ b/backend/src/mongodb/coordination_team.go @@ -0,0 +1,231 @@ +package mongodb + +import ( + "context" + "errors" + + "github.com/sinfo/deck2/src/models" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" +) + +// CoordinationTeamsType contains database info for coordination teams +type CoordinationTeamsType struct { + Collection *mongo.Collection +} + +// CreateCoordinationTeam inserts a new coordination team +func (c *CoordinationTeamsType) CreateCoordinationTeam(name string) (*models.CoordinationTeam, error) { + ctx := context.Background() + + insertResult, err := c.Collection.InsertOne(ctx, bson.M{ + "name": name, + // coordinator is intentionally omitted until set + "coordinatedMembers": []primitive.ObjectID{}, + }) + if err != nil { + return nil, err + } + + id := insertResult.InsertedID.(primitive.ObjectID) + return c.GetCoordinationTeam(id) +} + +// GetCoordinationTeam retrieves a coordination team by ID +func (c *CoordinationTeamsType) GetCoordinationTeam(id primitive.ObjectID) (*models.CoordinationTeam, error) { + ctx := context.Background() + var ct models.CoordinationTeam + if err := c.Collection.FindOne(ctx, bson.M{"_id": id}).Decode(&ct); err != nil { + return nil, err + } + return &ct, nil +} + +// GetCoordinationTeamsByMember finds coordination teams that include the given member id +func (c *CoordinationTeamsType) GetCoordinationTeamsByMember(memberID primitive.ObjectID) ([]*models.CoordinationTeam, error) { + ctx := context.Background() + cur, err := c.Collection.Find(ctx, bson.M{"coordinatedMembers": memberID}) + if err != nil { + return nil, err + } + + var res []*models.CoordinationTeam + for cur.Next(ctx) { + var ct models.CoordinationTeam + if err := cur.Decode(&ct); err != nil { + return nil, err + } + res = append(res, &ct) + } + cur.Close(ctx) + return res, nil +} + +// GetCoordinationTeamsByCoordinator finds coordination teams where the provided member is a coordinator +func (c *CoordinationTeamsType) GetCoordinationTeamsByCoordinator(memberID primitive.ObjectID) ([]*models.CoordinationTeam, error) { + ctx := context.Background() + cur, err := c.Collection.Find(ctx, bson.M{"coordinator.member": memberID}) + if err != nil { + return nil, err + } + + var res []*models.CoordinationTeam + for cur.Next(ctx) { + var ct models.CoordinationTeam + if err := cur.Decode(&ct); err != nil { + return nil, err + } + res = append(res, &ct) + } + cur.Close(ctx) + return res, nil +} + +// AddCoordinatedMember adds a member id to the coordinatedMembers list +func (c *CoordinationTeamsType) AddCoordinatedMember(coordTeamID, memberID primitive.ObjectID) (*models.CoordinationTeam, error) { + ctx := context.Background() + + ct, err := c.GetCoordinationTeam(coordTeamID) + if err != nil { + return nil, err + } + + for _, m := range ct.CoordinatedMembers { + if m == memberID { + return nil, errors.New("already exists") + } + } + + ct.CoordinatedMembers = append(ct.CoordinatedMembers, memberID) + + if _, err := c.Collection.UpdateOne(ctx, bson.M{"_id": coordTeamID}, bson.M{"$set": bson.M{"coordinatedMembers": ct.CoordinatedMembers}}); err != nil { + return nil, err + } + + return c.GetCoordinationTeam(coordTeamID) +} + +// RemoveCoordinatedMember removes a member id from the coordinatedMembers list +func (c *CoordinationTeamsType) RemoveCoordinatedMember(coordTeamID, memberID primitive.ObjectID) (*models.CoordinationTeam, error) { + ctx := context.Background() + + ct, err := c.GetCoordinationTeam(coordTeamID) + if err != nil { + return nil, err + } + + found := false + newList := make([]primitive.ObjectID, 0, len(ct.CoordinatedMembers)) + for _, m := range ct.CoordinatedMembers { + if m == memberID { + found = true + continue + } + newList = append(newList, m) + } + + if !found { + return nil, errors.New("not found") + } + + if _, err := c.Collection.UpdateOne(ctx, bson.M{"_id": coordTeamID}, bson.M{"$set": bson.M{"coordinatedMembers": newList}}); err != nil { + return nil, err + } + + return c.GetCoordinationTeam(coordTeamID) +} + +// SetCoordinator sets the single coordinator for this coordination team. It replaces existing coordinators. +// If name is non-empty it will also update the coordination team's name in the same operation. +func (c *CoordinationTeamsType) SetCoordinator(coordTeamID primitive.ObjectID, member models.TeamMember, name string) (*models.CoordinationTeam, error) { + ctx := context.Background() + + if member.Role != models.RoleCoordinator { + member.Role = models.RoleCoordinator + } + + set := bson.M{"coordinator": member} + if len(name) > 0 { + set["name"] = name + } + + if _, err := c.Collection.UpdateOne(ctx, bson.M{"_id": coordTeamID}, bson.M{"$set": set}); err != nil { + return nil, err + } + + return c.GetCoordinationTeam(coordTeamID) +} + +// RemoveCoordinator removes a coordinator by member id +func (c *CoordinationTeamsType) RemoveCoordinator(coordTeamID, memberID primitive.ObjectID) (*models.CoordinationTeam, error) { + ctx := context.Background() + + ct, err := c.GetCoordinationTeam(coordTeamID) + if err != nil { + return nil, err + } + + if ct.Coordinator == nil || ct.Coordinator.Member != memberID { + return nil, errors.New("not found") + } + + // unset the coordinator field + if _, err := c.Collection.UpdateOne(ctx, bson.M{"_id": coordTeamID}, bson.M{"$unset": bson.M{"coordinator": ""}}); err != nil { + return nil, err + } + + return c.GetCoordinationTeam(coordTeamID) +} + +// UpdateName updates the coordination team's name +func (c *CoordinationTeamsType) UpdateName(coordTeamID primitive.ObjectID, name string) (*models.CoordinationTeam, error) { + ctx := context.Background() + + if _, err := c.Collection.UpdateOne(ctx, bson.M{"_id": coordTeamID}, bson.M{"$set": bson.M{"name": name}}); err != nil { + return nil, err + } + + return c.GetCoordinationTeam(coordTeamID) +} + +// GetAllCoordinationTeams lists all coordination teams +func (c *CoordinationTeamsType) GetAllCoordinationTeams() ([]*models.CoordinationTeam, error) { + ctx := context.Background() + cur, err := c.Collection.Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + + var res []*models.CoordinationTeam + for cur.Next(ctx) { + var ct models.CoordinationTeam + if err := cur.Decode(&ct); err != nil { + return nil, err + } + res = append(res, &ct) + } + cur.Close(ctx) + return res, nil +} + +// DeleteCoordinationTeam deletes a coordination team and returns the deleted document +func (c *CoordinationTeamsType) DeleteCoordinationTeam(coordTeamID primitive.ObjectID) (*models.CoordinationTeam, error) { + ctx := context.Background() + + ct, err := c.GetCoordinationTeam(coordTeamID) + if err != nil { + return nil, err + } + + delRes, err := c.Collection.DeleteOne(ctx, bson.M{"_id": coordTeamID}) + if err != nil { + return nil, err + } + + if delRes.DeletedCount != 1 { + return nil, errors.New("could not delete coordination team") + } + + return ct, nil +} diff --git a/backend/src/mongodb/init.go b/backend/src/mongodb/init.go index c8b3f3e7..8e7d102e 100644 --- a/backend/src/mongodb/init.go +++ b/backend/src/mongodb/init.go @@ -52,6 +52,8 @@ var ( Notifications *NotificationsType //Templates is an instance of a mongodb collection Templates *TemplateType + // CoordinationTeams is an instance of coordination teams collection + CoordinationTeams *CoordinationTeamsType ) var ( @@ -179,6 +181,10 @@ func InitializeDatabase() { Collection: db.Collection("templates"), } + CoordinationTeams = &CoordinationTeamsType{ + Collection: db.Collection("coordinationTeams"), + } + // Ensure index for categories if err := Categories.EnsureIndexes(); err != nil { log.Println("Warning: failed to ensure categories indexes:", err) diff --git a/backend/src/mongodb/notification.go b/backend/src/mongodb/notification.go index 082c1a2f..b82ebe1c 100644 --- a/backend/src/mongodb/notification.go +++ b/backend/src/mongodb/notification.go @@ -84,23 +84,20 @@ func (n *NotificationsType) Notify(author primitive.ObjectID, data CreateNotific } } - // notify coordination on the author's team - for _, teamID := range event.Teams { - team, err := Teams.GetTeam(teamID) - if err != nil || !team.HasMember(author) { - continue - } - - coordinators := team.GetMembersByRole(models.RoleCoordinator) - - for _, coordinator := range coordinators { - - // notify authors only if not running on production mode - if config.Production && coordinator.Member == author { + // Notify coordination teams: coordination logic lives in separate collection + // Find coordination teams that include the author as a coordinated member + // and notify those coordination teams' coordinators only. + coordTeams, err := CoordinationTeams.GetCoordinationTeamsByMember(author) + if err == nil { + for _, coordTeam := range coordTeams { + if coordTeam.Coordinator == nil { continue } - - n.NotifyMember(coordinator.Member, data) + // do not notify the author themselves in production + if config.Production && coordTeam.Coordinator.Member == author { + continue + } + n.NotifyMember(coordTeam.Coordinator.Member, data) } } diff --git a/backend/src/mongodb/speaker.go b/backend/src/mongodb/speaker.go index 4383d212..f7c6e77b 100644 --- a/backend/src/mongodb/speaker.go +++ b/backend/src/mongodb/speaker.go @@ -17,7 +17,7 @@ import ( "go.mongodb.org/mongo-driver/mongo" ) -//SpeakersType contains database information on speakers +// SpeakersType contains database information on speakers type SpeakersType struct { Collection *mongo.Collection } @@ -25,7 +25,7 @@ type SpeakersType struct { // Cached version of the public speakers for the current event var currentPublicSpeakers *[]*models.SpeakerPublic -//ResetCurrentPublicSpeakers resets current public speakers +// ResetCurrentPublicSpeakers resets current public speakers func ResetCurrentPublicSpeakers() { currentPublicSpeakers = nil } @@ -33,11 +33,11 @@ func ResetCurrentPublicSpeakers() { func speakerToPublic(speaker models.Speaker, eventID *int) (*models.SpeakerPublic, error) { public := models.SpeakerPublic{ - ID: speaker.ID, - Name: speaker.Name, - Bio: speaker.Bio, - Title: speaker.Title, - CompanyName: speaker.CompanyName, + ID: speaker.ID, + Name: speaker.Name, + Bio: speaker.Bio, + Title: speaker.Title, + CompanyName: speaker.CompanyName, Images: models.SpeakerImagesPublic{ Speaker: speaker.Images.Speaker, Company: speaker.Images.Company, @@ -73,12 +73,12 @@ func speakerToPublic(speaker models.Speaker, eventID *int) (*models.SpeakerPubli return &public, nil } -//CreateSpeakerData holds data needed to create a speaker +// CreateSpeakerData holds data needed to create a speaker type CreateSpeakerData struct { - Name *string `json:"name"` - Title *string `json:"title"` - Bio *string `json:"bio"` - CompanyName *string `json:"companyName"` + Name *string `json:"name"` + Title *string `json:"title"` + Bio *string `json:"bio"` + CompanyName *string `json:"companyName"` Contact *models.Contact `json:"contact,omitempty"` } @@ -104,7 +104,7 @@ func (cpd *CreateSpeakerData) ParseBody(body io.Reader) error { // Don't enforce yet, deck2 flutter doesn't submit companyName /* if cpd.CompanyName == nil || len(*cpd.CompanyName) == 0 { return errors.New("invalid company name") - } */ + } */ if cpd.Contact != nil { for _, s := range cpd.Contact.Phones { @@ -339,6 +339,44 @@ func (s *SpeakersType) GetSpeakers(speakOptions GetSpeakersOptions) ([]*models.S return speakers, nil } +// GetSpeakersByMembers returns speakers that have a participation with any of the provided member IDs. +// If eventID is provided, only participations for that event are considered. +func (s *SpeakersType) GetSpeakersByMembers(memberIDs []primitive.ObjectID, eventID *int) ([]*models.Speaker, error) { + ctx := context.Background() + var speakers = make([]*models.Speaker, 0) + + filter := bson.M{} + + if eventID != nil { + // match participations where member in list AND event equals provided + filter["participations"] = bson.M{"$elemMatch": bson.M{"member": bson.M{"$in": memberIDs}, "event": *eventID}} + } else { + // match any document with participations.member in provided list + filter["participations.member"] = bson.M{"$in": memberIDs} + } + + cur, err := s.Collection.Find(ctx, filter) + if err != nil { + return nil, err + } + + for cur.Next(ctx) { + var sp models.Speaker + if err := cur.Decode(&sp); err != nil { + return nil, err + } + speakers = append(speakers, &sp) + } + + if err := cur.Err(); err != nil { + return nil, err + } + + cur.Close(ctx) + + return speakers, nil +} + // GetSpeakersPublicOptions is the options to give to GetCompanies. // All the fields are optional, and as such we use pointers as a "hack" to deal // with non-existent fields. @@ -441,39 +479,39 @@ func (s *SpeakersType) DeleteSpeaker(speakerID primitive.ObjectID) (*models.Spea var speaker models.Speaker - currentSpeaker, err := s.GetSpeaker(speakerID) - if err != nil { - return nil, err - } + currentSpeaker, err := s.GetSpeaker(speakerID) + if err != nil { + return nil, err + } - for _, participation := range currentSpeaker.Participations { - _, err = s.RemoveSpeakerParticipation(speakerID, participation.Event) - if err != nil { - return nil, err - } - } + for _, participation := range currentSpeaker.Participations { + _, err = s.RemoveSpeakerParticipation(speakerID, participation.Event) + if err != nil { + return nil, err + } + } sessions, err := Sessions.GetSessions(GetSessionsOptions{Speaker: &speakerID}) if err != nil { return nil, err } - for _, session := range sessions { - _, err := Sessions.DeleteSession(session.ID) - if err != nil { - return nil, err - } - } + for _, session := range sessions { + _, err := Sessions.DeleteSession(session.ID) + if err != nil { + return nil, err + } + } err = s.Collection.FindOneAndDelete(ctx, bson.M{"_id": speakerID}).Decode(&speaker) if err != nil { return nil, err } - _, err = Contacts.DeleteContact(*speaker.Contact) - if err != nil { - return nil, err - } + _, err = Contacts.DeleteContact(*speaker.Contact) + if err != nil { + return nil, err + } return &speaker, nil } @@ -498,10 +536,10 @@ func (s *SpeakersType) GetSpeakerPublic(speakerID primitive.ObjectID) (*models.S // UpdateSpeakerData is the data used to update a speaker, using the method UpdateSpeaker. type UpdateSpeakerData struct { - Name *string - Bio *string - Title *string - Notes *string + Name *string + Bio *string + Title *string + Notes *string CompanyName *string } @@ -666,7 +704,7 @@ func (s *SpeakersType) AddParticipation(speakerID primitive.ObjectID, memberID p return &updatedSpeaker, nil } -//UpdateSpeakerParticipationData holds data needed to update a speakers' participation +// UpdateSpeakerParticipationData holds data needed to update a speakers' participation type UpdateSpeakerParticipationData struct { Member *primitive.ObjectID `json:"member"` Feedback *string `json:"feedback"` @@ -1084,7 +1122,7 @@ func (s *SpeakersType) RemoveCommunication(speakerID primitive.ObjectID, threadI return &updatedSpeaker, nil } -//FindThread finds a thread in a speaker +// FindThread finds a thread in a speaker func (s *SpeakersType) FindThread(threadID primitive.ObjectID) (*models.Speaker, error) { ctx := context.Background() filter := bson.M{ @@ -1120,18 +1158,18 @@ func (s *SpeakersType) RemoveSpeakerParticipation(speakerID primitive.ObjectID, for _, p := range speaker.Participations { if p.Event == eventID { - for _, f := range p.Flights { - _, err := s.RemoveSpeakerFlightInfo(speakerID, f) - if err != nil { - return nil, err - } - } - for _, c := range p.Communications { - _, err := s.DeleteSpeakerThread(speakerID, c) - if err != nil { - return nil, err - } - } + for _, f := range p.Flights { + _, err := s.RemoveSpeakerFlightInfo(speakerID, f) + if err != nil { + return nil, err + } + } + for _, c := range p.Communications { + _, err := s.DeleteSpeakerThread(speakerID, c) + if err != nil { + return nil, err + } + } break } } diff --git a/backend/src/router/company.go b/backend/src/router/company.go index 2db81fe6..946ab988 100644 --- a/backend/src/router/company.go +++ b/backend/src/router/company.go @@ -154,6 +154,45 @@ func getCompaniesPublic(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(publicCompanies) } +// getCompaniesByMembers accepts a JSON body with { members: ["memberHex"], event?: number } +// and returns companies that have participations for any of these members (and optional event filter). +func getCompaniesByMembers(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var payload struct { + Members []string `json:"members"` + Event *int `json:"event,omitempty"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "Could not parse body: "+err.Error(), http.StatusBadRequest) + return + } + + if len(payload.Members) == 0 { + json.NewEncoder(w).Encode([]*models.Company{}) + return + } + + memberIDs := make([]primitive.ObjectID, 0, len(payload.Members)) + for _, m := range payload.Members { + id, err := primitive.ObjectIDFromHex(m) + if err != nil { + http.Error(w, "Invalid member ID format: "+err.Error(), http.StatusBadRequest) + return + } + memberIDs = append(memberIDs, id) + } + + companies, err := mongodb.Companies.GetCompaniesByMembers(memberIDs, payload.Event) + if err != nil { + http.Error(w, "Unable to get companies: "+err.Error(), http.StatusExpectationFailed) + return + } + + json.NewEncoder(w).Encode(companies) +} + func getCompanyPublic(w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) companyID, _ := primitive.ObjectIDFromHex(params["id"]) diff --git a/backend/src/router/coordination_team.go b/backend/src/router/coordination_team.go new file mode 100644 index 00000000..732eb049 --- /dev/null +++ b/backend/src/router/coordination_team.go @@ -0,0 +1,338 @@ +package router + +import ( + "encoding/json" + "net/http" + + "github.com/sinfo/deck2/src/models" + "github.com/sinfo/deck2/src/mongodb" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/gorilla/mux" +) + +type createCoordinationTeamBody struct { + Name string `json:"name"` +} + +type createCoordinationTeamCoordinatorBody struct { + Coordinator string `json:"coordinator"` +} + +type addCoordinatedTeamBody struct { + Member string `json:"member"` +} + +type setCoordinatorBody struct { + Member string `json:"member"` + Name string `json:"name,omitempty"` +} + +func getCoordinationTeams(w http.ResponseWriter, r *http.Request) { + teams, err := mongodb.CoordinationTeams.GetAllCoordinationTeams() + if err != nil { + http.Error(w, "Could not query coordination teams: "+err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(teams) +} + +func createCoordinationTeam(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var body createCoordinationTeamCoordinatorBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Could not parse body: "+err.Error(), http.StatusBadRequest) + return + } + + if len(body.Coordinator) == 0 { + http.Error(w, "invalid coordinator", http.StatusBadRequest) + return + } + + memberID, err := primitive.ObjectIDFromHex(body.Coordinator) + if err != nil { + http.Error(w, "Invalid coordinator id: "+err.Error(), http.StatusBadRequest) + return + } + + // validate that the provided member is a coordinator for the current event + event, err := mongodb.Events.GetCurrentEvent() + if err != nil { + http.Error(w, "Could not determine current event: "+err.Error(), http.StatusInternalServerError) + return + } + + isCoordinator := false + for _, teamID := range event.Teams { + team, err := mongodb.Teams.GetTeam(teamID) + if err != nil { + continue + } + for _, m := range team.GetMembersByRole(models.RoleCoordinator) { + if m.Member == memberID { + isCoordinator = true + break + } + } + if isCoordinator { + break + } + } + + if !isCoordinator { + http.Error(w, "member is not a coordinator in the current event", http.StatusBadRequest) + return + } + + // fetch member to derive the team name + member, err := mongodb.Members.GetMember(memberID) + if err != nil { + http.Error(w, "Could not fetch coordinator member: "+err.Error(), http.StatusInternalServerError) + return + } + + ct, err := mongodb.CoordinationTeams.CreateCoordinationTeam(member.Name) + if err != nil { + http.Error(w, "Could not create coordination team: "+err.Error(), http.StatusInternalServerError) + return + } + + // set coordinator + tm := models.TeamMember{Member: memberID, Role: models.RoleCoordinator} + ct, err = mongodb.CoordinationTeams.SetCoordinator(ct.ID, tm, member.Name) + if err != nil { + http.Error(w, "Could not set coordinator: "+err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(ct) +} + +func deleteCoordinationTeam(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + id, err := primitive.ObjectIDFromHex(params["id"]) + if err != nil { + http.Error(w, "Invalid id format: "+err.Error(), http.StatusBadRequest) + return + } + + ct, err := mongodb.CoordinationTeams.DeleteCoordinationTeam(id) + if err != nil { + http.Error(w, "Could not delete coordination team: "+err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(ct) +} + +func getCoordinationTeam(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + id, err := primitive.ObjectIDFromHex(params["id"]) + if err != nil { + http.Error(w, "Invalid id format: "+err.Error(), http.StatusBadRequest) + return + } + + ct, err := mongodb.CoordinationTeams.GetCoordinationTeam(id) + if err != nil { + http.Error(w, "Could not find coordination team: "+err.Error(), http.StatusNotFound) + return + } + + json.NewEncoder(w).Encode(ct) +} + +func getMyCoordinationTeams(w http.ResponseWriter, r *http.Request) { + // get credentials from context + credentials, ok := r.Context().Value(credentialsKey).(models.AuthorizationCredentials) + if !ok { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + + teams, err := mongodb.CoordinationTeams.GetCoordinationTeamsByCoordinator(credentials.ID) + if err != nil { + http.Error(w, "Could not query coordination teams: "+err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(teams) +} + +func updateCoordinationTeam(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := mux.Vars(r) + id, err := primitive.ObjectIDFromHex(params["id"]) + if err != nil { + http.Error(w, "Invalid id format: "+err.Error(), http.StatusBadRequest) + return + } + + var body createCoordinationTeamBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Could not parse body: "+err.Error(), http.StatusBadRequest) + return + } + + if len(body.Name) == 0 { + http.Error(w, "invalid name", http.StatusBadRequest) + return + } + + ct, err := mongodb.CoordinationTeams.UpdateName(id, body.Name) + if err != nil { + http.Error(w, "Could not update coordination team: "+err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(ct) +} + +func addCoordinatedTeam(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := mux.Vars(r) + id, err := primitive.ObjectIDFromHex(params["id"]) + if err != nil { + http.Error(w, "Invalid id format: "+err.Error(), http.StatusBadRequest) + return + } + + var body addCoordinatedTeamBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Could not parse body: "+err.Error(), http.StatusBadRequest) + return + } + + memberID, err := primitive.ObjectIDFromHex(body.Member) + if err != nil { + http.Error(w, "Invalid member id: "+err.Error(), http.StatusBadRequest) + return + } + + ct, err := mongodb.CoordinationTeams.AddCoordinatedMember(id, memberID) + if err != nil { + http.Error(w, "Could not add coordinated team: "+err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(ct) +} + +func removeCoordinatedTeam(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + id, err := primitive.ObjectIDFromHex(params["id"]) + if err != nil { + http.Error(w, "Invalid id format: "+err.Error(), http.StatusBadRequest) + return + } + memberID, err := primitive.ObjectIDFromHex(params["memberID"]) + if err != nil { + http.Error(w, "Invalid member id format: "+err.Error(), http.StatusBadRequest) + return + } + + ct, err := mongodb.CoordinationTeams.RemoveCoordinatedMember(id, memberID) + if err != nil { + http.Error(w, "Could not remove coordinated team: "+err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(ct) +} + +func setCoordinator(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := mux.Vars(r) + id, err := primitive.ObjectIDFromHex(params["id"]) + if err != nil { + http.Error(w, "Invalid id format: "+err.Error(), http.StatusBadRequest) + return + } + + var body setCoordinatorBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Could not parse body: "+err.Error(), http.StatusBadRequest) + return + } + + memberID, err := primitive.ObjectIDFromHex(body.Member) + if err != nil { + http.Error(w, "Invalid member id: "+err.Error(), http.StatusBadRequest) + return + } + + // validate that the provided member is a coordinator for the current event + event, err := mongodb.Events.GetCurrentEvent() + if err != nil { + http.Error(w, "Could not determine current event: "+err.Error(), http.StatusInternalServerError) + return + } + + isCoordinator := false + for _, teamID := range event.Teams { + team, err := mongodb.Teams.GetTeam(teamID) + if err != nil { + continue + } + for _, m := range team.GetMembersByRole(models.RoleCoordinator) { + if m.Member == memberID { + isCoordinator = true + break + } + } + if isCoordinator { + break + } + } + + if !isCoordinator { + http.Error(w, "member is not a coordinator in the current event", http.StatusBadRequest) + return + } + + tm := models.TeamMember{Member: memberID, Role: models.RoleCoordinator} + + // determine name: prefer provided name (optional) else derive from member's Name + name := body.Name + if len(name) == 0 { + member, err := mongodb.Members.GetMember(memberID) + if err != nil { + http.Error(w, "Could not fetch coordinator member: "+err.Error(), http.StatusInternalServerError) + return + } + name = member.Name + } + + ct, err := mongodb.CoordinationTeams.SetCoordinator(id, tm, name) + if err != nil { + http.Error(w, "Could not set coordinator: "+err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(ct) +} + +func removeCoordinator(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + id, err := primitive.ObjectIDFromHex(params["id"]) + if err != nil { + http.Error(w, "Invalid id format: "+err.Error(), http.StatusBadRequest) + return + } + memberID, err := primitive.ObjectIDFromHex(params["memberID"]) + if err != nil { + http.Error(w, "Invalid member id format: "+err.Error(), http.StatusBadRequest) + return + } + + ct, err := mongodb.CoordinationTeams.RemoveCoordinator(id, memberID) + if err != nil { + http.Error(w, "Could not remove coordinator: "+err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(ct) +} diff --git a/backend/src/router/init.go b/backend/src/router/init.go index 2e4d741b..b9f0b465 100644 --- a/backend/src/router/init.go +++ b/backend/src/router/init.go @@ -156,6 +156,8 @@ func InitializeRouter() { companyRouter.HandleFunc("/{id}", authMember(getCompany)).Methods("GET") companyRouter.HandleFunc("/{id}", authMember(updateCompany)).Methods("PUT") companyRouter.HandleFunc("/{id}", authCoordinator(deleteCompany)).Methods("DELETE") + // query companies by multiple member IDs + companyRouter.HandleFunc("/byMembers", authMember(getCompaniesByMembers)).Methods("POST") companyRouter.HandleFunc("/{id}/subscribe", authMember(subscribeToCompany)).Methods("PUT") companyRouter.HandleFunc("/{id}/unsubscribe", authMember(unsubscribeToCompany)).Methods("PUT") companyRouter.HandleFunc("/{id}/image/internal", authMember(setCompanyPrivateImage)).Methods("POST") @@ -183,6 +185,8 @@ func InitializeRouter() { speakerRouter.HandleFunc("", authMember(getSpeakers)).Methods("GET") speakerRouter.HandleFunc("", authMember(createSpeaker)).Methods("POST") speakerRouter.HandleFunc("/{id}", authMember(getSpeaker)).Methods("GET") + // query speakers by multiple member IDs + speakerRouter.HandleFunc("/byMembers", authMember(getSpeakersByMembers)).Methods("POST") speakerRouter.HandleFunc("/{id}", authCoordinator(deleteSpeaker)).Methods("DELETE") speakerRouter.HandleFunc("/{id}", authMember(updateSpeaker)).Methods("PUT") speakerRouter.HandleFunc("/{id}/subscribe", authMember(subscribeToSpeaker)).Methods("PUT") @@ -239,6 +243,19 @@ func InitializeRouter() { teamRouter.HandleFunc("/{id}/meetings", authMember(addTeamMeeting)).Methods("POST") teamRouter.HandleFunc("/{id}/meetings/{meetingID}", authTeamLeader(deleteTeamMeeting)).Methods("DELETE") + // coordination team handlers + coordRouter := r.PathPrefix("/coordinationTeams").Subrouter() + coordRouter.HandleFunc("", authMember(getCoordinationTeams)).Methods("GET") + coordRouter.HandleFunc("/me", authCoordinator(getMyCoordinationTeams)).Methods("GET") + coordRouter.HandleFunc("", authCoordinator(createCoordinationTeam)).Methods("POST") + coordRouter.HandleFunc("/{id}", authMember(getCoordinationTeam)).Methods("GET") + coordRouter.HandleFunc("/{id}", authCoordinator(updateCoordinationTeam)).Methods("PUT") + coordRouter.HandleFunc("/{id}", authCoordinator(deleteCoordinationTeam)).Methods("DELETE") + coordRouter.HandleFunc("/{id}/coordinatedMembers", authCoordinator(addCoordinatedTeam)).Methods("POST") + coordRouter.HandleFunc("/{id}/coordinatedMembers/{memberID}", authCoordinator(removeCoordinatedTeam)).Methods("DELETE") + coordRouter.HandleFunc("/{id}/coordinator", authCoordinator(setCoordinator)).Methods("POST") + coordRouter.HandleFunc("/{id}/coordinator/{memberID}", authCoordinator(removeCoordinator)).Methods("DELETE") + // me handlers meRouter := r.PathPrefix("/me").Subrouter() meRouter.HandleFunc("", authMember(getMe)).Methods("GET") diff --git a/backend/src/router/speaker.go b/backend/src/router/speaker.go index 36de625c..ed19f2e3 100644 --- a/backend/src/router/speaker.go +++ b/backend/src/router/speaker.go @@ -171,6 +171,46 @@ func getSpeakersPublic(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(publicSpeakers) } +// getSpeakersByMembers accepts a JSON body with { members: ["memberHex"], event?: number } +// and returns speakers that have participations for any of these members (and optional event filter). +func getSpeakersByMembers(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var payload struct { + Members []string `json:"members"` + Event *int `json:"event,omitempty"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "Could not parse body: "+err.Error(), http.StatusBadRequest) + return + } + + if len(payload.Members) == 0 { + // return empty list + json.NewEncoder(w).Encode([]*models.Speaker{}) + return + } + + memberIDs := make([]primitive.ObjectID, 0, len(payload.Members)) + for _, m := range payload.Members { + id, err := primitive.ObjectIDFromHex(m) + if err != nil { + http.Error(w, "Invalid member ID format: "+err.Error(), http.StatusBadRequest) + return + } + memberIDs = append(memberIDs, id) + } + + speakers, err := mongodb.Speakers.GetSpeakersByMembers(memberIDs, payload.Event) + if err != nil { + http.Error(w, "Unable to get speakers: "+err.Error(), http.StatusExpectationFailed) + return + } + + json.NewEncoder(w).Encode(speakers) +} + func createSpeaker(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() diff --git a/frontend/src/api/companies.ts b/frontend/src/api/companies.ts index 5573b63f..1f66cbe7 100644 --- a/frontend/src/api/companies.ts +++ b/frontend/src/api/companies.ts @@ -24,6 +24,9 @@ export const getAllCompanies = (filters: AllCompaniesFilter) => params: filters, }); +export const getCompaniesByMembers = (members: string[], event?: number) => + instance.post("/companies/byMembers", { members, event }); + export const getCompanyById = (id: string) => instance.get(`/companies/${id}`); diff --git a/frontend/src/api/coordinationTeams.ts b/frontend/src/api/coordinationTeams.ts new file mode 100644 index 00000000..8cabd30a --- /dev/null +++ b/frontend/src/api/coordinationTeams.ts @@ -0,0 +1,42 @@ +import { instance } from "."; +import type { + CoordinationTeam, + CreateCoordinationTeamData, + AddCoordinatedTeamData, + SetCoordinatorData, +} from "@/dto/coordinationTeams"; + +export const getAllCoordinationTeams = () => + instance.get(`/coordinationTeams`); + +export const getMyCoordinationTeams = () => + instance.get(`/coordinationTeams/me`); + +export const createCoordinationTeam = (data: CreateCoordinationTeamData) => + instance.post(`/coordinationTeams`, data); + +export const getCoordinationTeamById = (id: string) => + instance.get(`/coordinationTeams/${id}`); + +export const updateCoordinationTeamName = ( + id: string, + data: { name: string }, +) => instance.put(`/coordinationTeams/${id}`, data); + +export const deleteCoordinationTeam = (id: string) => + instance.delete(`/coordinationTeams/${id}`); + +export const addCoordinatedTeam = (id: string, data: AddCoordinatedTeamData) => + instance.post( + `/coordinationTeams/${id}/coordinatedMembers`, + data, + ); + +export const removeCoordinatedTeam = (id: string, memberId: string) => + instance.delete(`/coordinationTeams/${id}/coordinatedMembers/${memberId}`); + +export const setCoordinator = (id: string, data: SetCoordinatorData) => + instance.post(`/coordinationTeams/${id}/coordinator`, data); + +export const removeCoordinator = (id: string, memberId: string) => + instance.delete(`/coordinationTeams/${id}/coordinator/${memberId}`); diff --git a/frontend/src/api/members.ts b/frontend/src/api/members.ts index 8bd12fef..f4098321 100644 --- a/frontend/src/api/members.ts +++ b/frontend/src/api/members.ts @@ -16,5 +16,8 @@ export const getMe = () => instance.get("/me"); export const getMemberById = (id: string) => instance.get(`/members/${id}`); +export const getMemberRole = (id: string) => + instance.get<{ role: string }>(`/members/${id}/role`); + export const getMemberParticipations = (id: string) => instance.get(`/members/${id}/participations`); diff --git a/frontend/src/api/speakers.ts b/frontend/src/api/speakers.ts index 1e3b5966..155b234f 100644 --- a/frontend/src/api/speakers.ts +++ b/frontend/src/api/speakers.ts @@ -16,6 +16,9 @@ import { export const getAllSpeakers = (filter: AllSpeakersFilter) => instance.get("/speakers", { params: filter }); +export const getSpeakersByMembers = (members: string[], event?: number) => + instance.post("/speakers/byMembers", { members, event }); + export const getSpeakerById = (id: string) => instance.get(`/speakers/${id}`); diff --git a/frontend/src/api/teams.ts b/frontend/src/api/teams.ts new file mode 100644 index 00000000..becb3468 --- /dev/null +++ b/frontend/src/api/teams.ts @@ -0,0 +1,5 @@ +import { instance } from "."; +import type { Team } from "@/dto/teams"; + +export const getAllTeams = (params?: Record) => + instance.get("/teams", { params }); diff --git a/frontend/src/components/ConfirmDelete.vue b/frontend/src/components/ConfirmDelete.vue index 6a18e38e..fbd07e29 100644 --- a/frontend/src/components/ConfirmDelete.vue +++ b/frontend/src/components/ConfirmDelete.vue @@ -21,7 +21,6 @@ diff --git a/frontend/src/components/packages/PackageForm.vue b/frontend/src/components/packages/PackageForm.vue index b70454c1..ae598e0c 100644 --- a/frontend/src/components/packages/PackageForm.vue +++ b/frontend/src/components/packages/PackageForm.vue @@ -178,7 +178,6 @@ const submit = async () => { const invalidItems = findInvalidItemIds(payloadItems); if (invalidItems.length > 0) { const ids = invalidItems.join(", "); - // show toast instead of alert toast.error({ title: "Invalid items", description: `One or more item IDs are invalid. Invalid: ${ids}`, diff --git a/frontend/src/dto/coordinationTeams.ts b/frontend/src/dto/coordinationTeams.ts new file mode 100644 index 00000000..90ef93c9 --- /dev/null +++ b/frontend/src/dto/coordinationTeams.ts @@ -0,0 +1,22 @@ +import type { ObjectID } from "."; +import type { TeamMember } from "./teams"; + +export interface CoordinationTeam { + id: ObjectID; + name: string; + coordinator?: TeamMember | null; + coordinatedMembers: ObjectID[]; +} + +export interface CreateCoordinationTeamData { + coordinator: ObjectID; +} + +export interface AddCoordinatedTeamData { + member: ObjectID; +} + +export interface SetCoordinatorData { + member: ObjectID; + name?: string; +} diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 693a0299..6ce9996f 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -71,6 +71,24 @@ const router = createRouter({ import("./views/Dashboard/Packages/Items/PackageItemsView.vue"), meta: { roles: ["COORDINATOR", "ADMIN"] }, }, + { + path: "coord-teams", + name: "coordination-teams", + component: () => + import( + "./views/Dashboard/CoordinationTeams/CoordinationTeamsView.vue" + ), + meta: { roles: ["COORDINATOR", "ADMIN"] }, + }, + { + path: "me/coord-team", + name: "my-coordination-team", + component: () => + import( + "./views/Dashboard/CoordinationTeams/CoordinatorTeamView.vue" + ), + meta: { roles: ["COORDINATOR"] }, + }, ], }, ], diff --git a/frontend/src/views/Dashboard/CoordinationTeams/CoordinationTeamsView.vue b/frontend/src/views/Dashboard/CoordinationTeams/CoordinationTeamsView.vue new file mode 100644 index 00000000..fe0b5a6c --- /dev/null +++ b/frontend/src/views/Dashboard/CoordinationTeams/CoordinationTeamsView.vue @@ -0,0 +1,489 @@ + + + + + diff --git a/frontend/src/views/Dashboard/CoordinationTeams/CoordinatorTeamView.vue b/frontend/src/views/Dashboard/CoordinationTeams/CoordinatorTeamView.vue new file mode 100644 index 00000000..a73f39fd --- /dev/null +++ b/frontend/src/views/Dashboard/CoordinationTeams/CoordinatorTeamView.vue @@ -0,0 +1,396 @@ + + + + +