From 345878fabed1ca545c0c5934c8f3a5c7583e02fd Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Mon, 30 Apr 2018 17:50:39 -0700 Subject: [PATCH] Contact creation and invite UI --- blog.go | 2 + internal/controllers/events/events.go | 2 +- internal/controllers/events/invite.go | 85 ++++++++- models/contacts/contacts.go | 164 +++++++++++++++++ models/contacts/sorting.go | 10 ++ models/events/events.go | 15 -- models/events/invites.go | 70 ++++++++ models/events/sorting.go | 2 +- root/events/invite.gohtml | 249 +++++++++++++++++--------- root/events/view.gohtml | 2 +- 10 files changed, 488 insertions(+), 113 deletions(-) create mode 100644 models/contacts/contacts.go create mode 100644 models/contacts/sorting.go create mode 100644 models/events/invites.go diff --git a/blog.go b/blog.go index 6341a1a..401ee1e 100644 --- a/blog.go +++ b/blog.go @@ -26,6 +26,7 @@ import ( "github.com/kirsle/blog/jsondb/caches/null" "github.com/kirsle/blog/jsondb/caches/redis" "github.com/kirsle/blog/models/comments" + "github.com/kirsle/blog/models/contacts" "github.com/kirsle/blog/models/events" "github.com/kirsle/blog/models/posts" "github.com/kirsle/blog/models/settings" @@ -90,6 +91,7 @@ func (b *Blog) Configure() { posts.DB = b.DB users.DB = b.DB comments.DB = b.DB + contacts.DB = b.DB events.DB = b.DB // Redis cache? diff --git a/internal/controllers/events/events.go b/internal/controllers/events/events.go index 6b346b9..0cdf7bf 100644 --- a/internal/controllers/events/events.go +++ b/internal/controllers/events/events.go @@ -18,7 +18,7 @@ func Register(r *mux.Router, loginError http.HandlerFunc) { // Login-required routers. loginRouter := mux.NewRouter() loginRouter.HandleFunc("/e/admin/edit", editHandler) - loginRouter.HandleFunc("/e/admin/invite", inviteHandler) + loginRouter.HandleFunc("/e/admin/invite/{id}", inviteHandler) loginRouter.HandleFunc("/e/admin/", indexHandler) r.PathPrefix("/e/admin").Handler( negroni.New( diff --git a/internal/controllers/events/invite.go b/internal/controllers/events/invite.go index f52b143..3b22570 100644 --- a/internal/controllers/events/invite.go +++ b/internal/controllers/events/invite.go @@ -3,28 +3,101 @@ package events import ( "net/http" "strconv" + "strings" + "github.com/gorilla/mux" + "github.com/kirsle/blog/internal/log" "github.com/kirsle/blog/internal/render" "github.com/kirsle/blog/internal/responses" + "github.com/kirsle/blog/models/contacts" "github.com/kirsle/blog/models/events" ) func inviteHandler(w http.ResponseWriter, r *http.Request) { - v := map[string]interface{}{ - "preview": "", - } - - id, err := strconv.Atoi(r.FormValue("id")) + params := mux.Vars(r) + id, err := strconv.Atoi(params["id"]) if err != nil { responses.FlashAndRedirect(w, r, "/e/admin/", "Invalid ID") return } + + // Load the event from its ID. event, err := events.Load(id) if err != nil { responses.FlashAndRedirect(w, r, "/e/admin/", "Can't load event: %s", err) return } - v["event"] = event + // Get the address book. + addr, _ := contacts.Load() + + // Handle POST requests. + if r.Method == http.MethodPost { + action := r.FormValue("action") + + switch action { + case "new-contact": + c := contacts.NewContact() + c.ParseForm(r) + err = c.Validate() + if err != nil { + responses.FlashAndReload(w, r, "Validation error: %s", err) + return + } + + addr.Add(c) + err = addr.Save() + if err != nil { + responses.FlashAndReload(w, r, "Error when saving address book: %s", err) + return + } + + responses.FlashAndReload(w, r, "Added %s to the address book!", c.Name()) + return + case "send-invite": + log.Error("Send Invite!") + r.ParseForm() + contactIDs, ok := r.Form["invite"] + if !ok { + responses.Error(w, r, "Missing: invite (list of IDs)") + return + } + + // Invite all the users. + var warnings []string + for _, strID := range contactIDs { + id, _ := strconv.Atoi(strID) + err := event.InviteContactID(id) + if err != nil { + warnings = append(warnings, err.Error()) + } + } + if len(warnings) > 0 { + responses.Flash(w, r, "Warnings: %s", strings.Join(warnings, "; ")) + } + responses.FlashAndReload(w, r, "Invites sent!") + return + } + } + + invited, err := event.Invited() + if err != nil { + log.Error("error getting event.Invited: %s", err) + } + + // Map the invited user IDs. + invitedMap := map[int]bool{} + for _, rsvp := range invited { + if rsvp.ContactID != 0 { + invitedMap[rsvp.ContactID] = true + } + } + + v := map[string]interface{}{ + "event": event, + "invited": invited, + "invitedMap": invitedMap, + "contacts": addr, + } render.Template(w, r, "events/invite", v) } diff --git a/models/contacts/contacts.go b/models/contacts/contacts.go new file mode 100644 index 0000000..dd9b740 --- /dev/null +++ b/models/contacts/contacts.go @@ -0,0 +1,164 @@ +package contacts + +import ( + "errors" + "net/http" + "sort" + "strings" + "time" + + "github.com/kirsle/blog/jsondb" + "github.com/kirsle/golog" +) + +// DB is a reference to the parent app's JsonDB object. +var DB *jsondb.DB + +var log *golog.Logger + +func init() { + log = golog.GetLogger("blog") +} + +// Contacts is an address book of users who have been invited to events. +type Contacts struct { + Serial int `json:"serial"` + Contacts []*Contact `json:"contacts"` +} + +// Contact is an individual contact in the address book. +type Contact struct { + ID int `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email"` + SMS string `json:"sms"` + LastSeen time.Time `json:"lastSeen"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// NewContact initializes a new contact entry. +func NewContact() *Contact { + return &Contact{} +} + +// Load the singleton contact list. +func Load() (*Contacts, error) { + c := &Contacts{ + Serial: 1, + Contacts: []*Contact{}, + } + if DB.Exists("contacts/address-book") { + err := DB.Get("contacts/address-book", &c) + return c, err + } + return c, nil +} + +// Add a new contact. +func (cl *Contacts) Add(c *Contact) { + if c.ID == 0 { + c.ID = cl.Serial + cl.Serial++ + } + + if c.Created.IsZero() { + c.Created = time.Now().UTC() + } + if c.Updated.IsZero() { + c.Updated = time.Now().UTC() + } + cl.Contacts = append(cl.Contacts, c) +} + +// Save the contact list. +func (cl *Contacts) Save() error { + sort.Sort(ByName(cl.Contacts)) + return DB.Commit("contacts/address-book", cl) +} + +// GetID queries a contact by its ID number. +func (cl *Contacts) GetID(id int) (*Contact, error) { + for _, c := range cl.Contacts { + if c.ID == id { + return c, nil + } + } + return nil, errors.New("not found") +} + +// GetEmail queries a contact by email address. +func (cl *Contacts) GetEmail(email string) (*Contact, error) { + email = strings.ToLower(email) + for _, c := range cl.Contacts { + if c.Email == email { + return c, nil + } + } + return nil, errors.New("not found") +} + +// GetSMS queries a contact by SMS number. +func (cl *Contacts) GetSMS(number string) (*Contact, error) { + for _, c := range cl.Contacts { + if c.SMS == number { + return c, nil + } + } + return nil, errors.New("not found") +} + +// Name returns a friendly name for the contact. +func (c *Contact) Name() string { + var parts []string + if c.FirstName != "" { + parts = append(parts, c.FirstName) + } + if c.LastName != "" { + parts = append(parts, c.LastName) + } + if len(parts) == 0 { + if c.Email != "" { + parts = append(parts, c.Email) + } else if c.SMS != "" { + parts = append(parts, c.SMS) + } + } + return strings.Join(parts, " ") +} + +// ParseForm accepts form data for a contact. +func (c *Contact) ParseForm(r *http.Request) { + c.FirstName = r.FormValue("first_name") + c.LastName = r.FormValue("last_name") + c.Email = strings.ToLower(r.FormValue("email")) + c.SMS = r.FormValue("sms") +} + +// Validate the contact form. +func (c *Contact) Validate() error { + if c.Email == "" && c.SMS == "" { + return errors.New("email or sms number required") + } + if c.FirstName == "" && c.LastName == "" { + return errors.New("first or last name required") + } + + // Get the address book out. + addr, _ := Load() + + // Check for uniqueness of email and SMS. + if c.Email != "" { + if _, err := addr.GetEmail(c.Email); err == nil { + return errors.New("email address already exists") + } + } + if c.SMS != "" { + if _, err := addr.GetSMS(c.SMS); err == nil { + return errors.New("sms number already exists") + } + } + + return nil +} diff --git a/models/contacts/sorting.go b/models/contacts/sorting.go new file mode 100644 index 0000000..03ebb5d --- /dev/null +++ b/models/contacts/sorting.go @@ -0,0 +1,10 @@ +package contacts + +// ByName sorts contacts by name. +type ByName []*Contact + +func (a ByName) Len() int { return len(a) } +func (a ByName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByName) Less(i, j int) bool { + return a[i].Name() < a[j].Name() +} diff --git a/models/events/events.go b/models/events/events.go index 3fe4d4a..51096aa 100644 --- a/models/events/events.go +++ b/models/events/events.go @@ -39,21 +39,6 @@ type Event struct { Updated time.Time `json:"updated"` } -// RSVP tracks invitations and confirmations to events. -type RSVP struct { - // If the user was invited by an admin, they will have a ContactID and - // not much else. Users who signed up themselves from an OpenSignup event - // will have the metadata filled in instead. - ContactID int `json:"contactId"` - Notified bool `json:"notified"` - Name string `json:"name,omitempty"` - Status string `json:"status,omitempty"` // invited, going, maybe, not going - Email string `json:"email,omitempty"` - SMS string `json:"sms,omitempty"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` -} - // New creates a blank event with sensible defaults. func New() *Event { return &Event{ diff --git a/models/events/invites.go b/models/events/invites.go new file mode 100644 index 0000000..f173107 --- /dev/null +++ b/models/events/invites.go @@ -0,0 +1,70 @@ +package events + +import ( + "errors" + "fmt" + "time" + + "github.com/kirsle/blog/models/contacts" +) + +// RSVP status constants. +const ( + StatusInvited = "invited" + StatusGoing = "going" + StatusMaybe = "maybe" + StatusNotGoing = "not going" +) + +// RSVP tracks invitations and confirmations to events. +type RSVP struct { + // If the user was invited by an admin, they will have a ContactID and + // not much else. Users who signed up themselves from an OpenSignup event + // will have the metadata filled in instead. + ContactID int `json:"contactId"` + Contact *contacts.Contact `json:"-"` // rel table not serialized to JSON + Status string `json:"status"` // invited, going, maybe, not going + Notified bool `json:"notified"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + SMS string `json:"sms,omitempty"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// InviteContactID enters an invitation for a contact ID. +func (ev *Event) InviteContactID(id int) error { + // Make sure the ID isn't already in the list. + for _, rsvp := range ev.RSVP { + if rsvp.ContactID != 0 && rsvp.ContactID == id { + return errors.New("already invited") + } + } + + ev.RSVP = append(ev.RSVP, RSVP{ + ContactID: id, + Status: StatusInvited, + Created: time.Now().UTC(), + Updated: time.Now().UTC(), + }) + return ev.Save() +} + +// Invited returns the RSVPs with Contact objects injected for contacts. +func (ev *Event) Invited() ([]RSVP, error) { + cl, _ := contacts.Load() + result := []RSVP{} + for _, rsvp := range ev.RSVP { + if rsvp.ContactID != 0 { + fmt.Printf("cid: %d\n", rsvp.ContactID) + c, err := cl.GetID(rsvp.ContactID) + if err != nil { + fmt.Printf("event.Invited error: %s", err) + } + rsvp.Contact = c + } + result = append(result, rsvp) + } + + return result, nil +} diff --git a/models/events/sorting.go b/models/events/sorting.go index 4e8258d..e8ef7ac 100644 --- a/models/events/sorting.go +++ b/models/events/sorting.go @@ -6,5 +6,5 @@ type ByDate []*Event func (a ByDate) Len() int { return len(a) } func (a ByDate) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByDate) Less(i, j int) bool { - return a[i].StartTime.Before(a[i].StartTime) || a[i].ID < a[j].ID + return a[i].StartTime.Before(a[j].StartTime) || a[i].ID < a[j].ID } diff --git a/root/events/invite.gohtml b/root/events/invite.gohtml index 1216c8b..3679be5 100644 --- a/root/events/invite.gohtml +++ b/root/events/invite.gohtml @@ -1,108 +1,179 @@ {{ define "title" }}Invite People to {{ .Data.event.Title }}{{ end }} {{ define "content" }} -
- - {{ $e := .Data.event }} -

Invite {{ $e.Title }}

+{{ $e := .Data.event }} +{{ $cl := .Data.contacts }} -
-
Contact List
-
-
-
-

Invited

+

Invite {{ $e.Title }}

-
    -
  • - John Doe
    - name@example.com -
  • -
  • - John Doe
    - name@example.com -
  • -
-
-
-

Available

+
+
Contact List
+
+
+
+

Invited

-
    -
  • - -
  • -
  • - -
  • -
+
    + {{ range .Data.invited }} +
  • +
    + {{ if .Contact }} + {{ .Contact.Name }} + {{ else }} + {{ .Name }} + {{ end }} + +
    +
  • + {{ end }} +
  • + John Doe
    + name@example.com +
  • +
  • + John Doe
    + name@example.com +
  • +
+
+
+

Available

- -
+ + + +
    + {{ range $cl.Contacts }} + {{ if not (index $.Data.invitedMap .ID) }} +
  • + +
  • + {{ end }} + {{ end }} +
  • + +
  • +
  • + +
  • +
+ + + Manage Contacts + +
+
-
-
Invite New People
-
-
-
- - -
-
- - -
+
+
Invite New People
+
+
+ + +
+
+ +
- -
-
- - -
-
- - -
-
- -
- +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+
-

Invited

+

Invited

- To Do - +To Do {{ end }} diff --git a/root/events/view.gohtml b/root/events/view.gohtml index 21ebee0..7730e80 100644 --- a/root/events/view.gohtml +++ b/root/events/view.gohtml @@ -38,7 +38,7 @@ {{ end }}