From a166c72cf36fde5ec234f64f1c647acd3757592f Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Fri, 11 May 2018 20:15:16 -0700 Subject: [PATCH] SQLite DB, initial invite flows and UX --- blog.go | 31 ++++-- cmd/blog/main.go | 1 + internal/controllers/events/events.go | 16 +-- internal/controllers/events/invite.go | 44 ++++++-- internal/controllers/events/notifier.go | 40 +++++++ internal/mail/mail.go | 15 ++- models/contacts/contacts.go | 135 ++++++++++++------------ models/events/events.go | 93 ++++++---------- models/events/index.go | 60 ----------- models/events/invites.go | 80 +++++++++----- models/events/sorting.go | 22 ++++ root/.email/event-invite.gohtml | 48 +++++++++ root/events/invite.gohtml | 98 ++++++++++++----- root/events/view.gohtml | 51 +++++++++ 14 files changed, 456 insertions(+), 278 deletions(-) create mode 100644 internal/controllers/events/notifier.go delete mode 100644 models/events/index.go create mode 100644 root/.email/event-invite.gohtml diff --git a/blog.go b/blog.go index 401ee1e..8546846 100644 --- a/blog.go +++ b/blog.go @@ -7,6 +7,7 @@ import ( "path/filepath" "github.com/gorilla/mux" + "github.com/jinzhu/gorm" "github.com/kirsle/blog/internal/controllers/admin" "github.com/kirsle/blog/internal/controllers/authctl" commentctl "github.com/kirsle/blog/internal/controllers/comments" @@ -44,8 +45,9 @@ type Blog struct { DocumentRoot string UserRoot string - DB *jsondb.DB - Cache caches.Cacher + db *gorm.DB + jsonDB *jsondb.DB + Cache caches.Cacher // Web app objects. n *negroni.Negroni // Negroni middleware manager @@ -54,10 +56,16 @@ type Blog struct { // New initializes the Blog application. func New(documentRoot, userRoot string) *Blog { + db, err := gorm.Open("sqlite3", filepath.Join(userRoot, ".private", "database.sqlite")) + if err != nil { + panic(err) + } + return &Blog{ DocumentRoot: documentRoot, UserRoot: userRoot, - DB: jsondb.New(filepath.Join(userRoot, ".private")), + db: db, + jsonDB: jsondb.New(filepath.Join(userRoot, ".private")), Cache: null.New(), } } @@ -72,8 +80,11 @@ func (b *Blog) Run(address string) { // Configure initializes (or reloads) the blog's configuration, and binds the // settings in sub-packages. func (b *Blog) Configure() { + if b.Debug { + b.db.LogMode(true) + } // Load the site config, or start with defaults if not found. - settings.DB = b.DB + settings.DB = b.jsonDB config, err := settings.Load() if err != nil { config = settings.Defaults() @@ -88,11 +99,11 @@ func (b *Blog) Configure() { users.HashCost = config.Security.HashCost // Initialize the rest of the models. - posts.DB = b.DB - users.DB = b.DB - comments.DB = b.DB - contacts.DB = b.DB - events.DB = b.DB + contacts.UseDB(b.db) + events.UseDB(b.db) + posts.DB = b.jsonDB + users.DB = b.jsonDB + comments.DB = b.jsonDB // Redis cache? if config.Redis.Enabled { @@ -107,7 +118,7 @@ func (b *Blog) Configure() { log.Error("Redis init error: %s", err.Error()) } else { b.Cache = cache - b.DB.Cache = cache + b.jsonDB.Cache = cache markdown.Cache = cache } } diff --git a/cmd/blog/main.go b/cmd/blog/main.go index 69e89ef..ad9bc0a 100644 --- a/cmd/blog/main.go +++ b/cmd/blog/main.go @@ -9,6 +9,7 @@ import ( "fmt" "os" + _ "github.com/jinzhu/gorm/dialects/sqlite" // SQLite DB "github.com/kirsle/blog" "github.com/kirsle/blog/jsondb" ) diff --git a/internal/controllers/events/events.go b/internal/controllers/events/events.go index 0cdf7bf..b8cda61 100644 --- a/internal/controllers/events/events.go +++ b/internal/controllers/events/events.go @@ -33,17 +33,9 @@ func Register(r *mux.Router, loginError http.HandlerFunc) { // Admin index to view all events. func indexHandler(w http.ResponseWriter, r *http.Request) { - result := []*events.Event{} - docs, _ := events.DB.List("events/by-id") - for _, doc := range docs { - ev := &events.Event{} - err := events.DB.Get(doc, &ev) - if err != nil { - log.Error("error reading %s: %s", doc, err) - continue - } - - result = append(result, ev) + result, err := events.All() + if err != nil { + log.Error("error listing all events: %s", err) } sort.Sort(sort.Reverse(events.ByDate(result))) @@ -68,6 +60,8 @@ func viewHandler(w http.ResponseWriter, r *http.Request) { return } + sort.Sort(events.ByName(event.RSVP)) + v := map[string]interface{}{ "event": event, } diff --git a/internal/controllers/events/invite.go b/internal/controllers/events/invite.go index 3b22570..72d424d 100644 --- a/internal/controllers/events/invite.go +++ b/internal/controllers/events/invite.go @@ -28,9 +28,6 @@ func inviteHandler(w http.ResponseWriter, r *http.Request) { return } - // Get the address book. - addr, _ := contacts.Load() - // Handle POST requests. if r.Method == http.MethodPost { action := r.FormValue("action") @@ -45,14 +42,18 @@ func inviteHandler(w http.ResponseWriter, r *http.Request) { return } - addr.Add(c) - err = addr.Save() + err = contacts.Add(&c) 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()) + err = event.InviteContactID(c.ID) + if err != nil { + responses.Flash(w, r, "Error: couldn't invite contact: %s", err) + } + + responses.FlashAndReload(w, r, "Added %s to the address book and added to invite list!", c.Name()) return case "send-invite": log.Error("Send Invite!") @@ -67,7 +68,8 @@ func inviteHandler(w http.ResponseWriter, r *http.Request) { var warnings []string for _, strID := range contactIDs { id, _ := strconv.Atoi(strID) - err := event.InviteContactID(id) + err = event.InviteContactID(id) + log.Debug("Inviting contact ID %d: err=%s", id, err) if err != nil { warnings = append(warnings, err.Error()) } @@ -77,10 +79,29 @@ func inviteHandler(w http.ResponseWriter, r *http.Request) { } responses.FlashAndReload(w, r, "Invites sent!") return + case "revoke-invite": + idx, _ := strconv.Atoi(r.FormValue("index")) + err := event.Uninvite(idx) + if err != nil { + responses.FlashAndReload(w, r, "Error deleting the invite: %s", err) + return + } + responses.FlashAndReload(w, r, "Invite revoked!") + return + case "notify": + // Notify all the invited users! + for _, rsvp := range event.RSVP { + if !rsvp.Notified || true { + log.Info("Notify RSVP %s about Event %s", rsvp.GetName(), event.Title) + notifyUser(event, rsvp) + } + } + responses.FlashAndReload(w, r, "Notification emails and SMS messages sent out!") + return } } - invited, err := event.Invited() + invited := event.RSVP if err != nil { log.Error("error getting event.Invited: %s", err) } @@ -93,11 +114,16 @@ func inviteHandler(w http.ResponseWriter, r *http.Request) { } } + allContacts, err := contacts.All() + if err != nil { + log.Error("contacts.All() error: %s", err) + } + v := map[string]interface{}{ "event": event, "invited": invited, "invitedMap": invitedMap, - "contacts": addr, + "contacts": allContacts, } render.Template(w, r, "events/invite", v) } diff --git a/internal/controllers/events/notifier.go b/internal/controllers/events/notifier.go new file mode 100644 index 0000000..f213b7f --- /dev/null +++ b/internal/controllers/events/notifier.go @@ -0,0 +1,40 @@ +package events + +import ( + "fmt" + "strings" + + "github.com/kirsle/blog/internal/mail" + "github.com/kirsle/blog/models/events" + "github.com/kirsle/blog/models/settings" +) + +func notifyUser(ev *events.Event, rsvp events.RSVP) { + var ( + email = rsvp.GetEmail() + sms = rsvp.GetSMS() + ) + s, _ := settings.Load() + + // Do they have... an e-mail address? + if email != "" { + mail.SendEmail(mail.Email{ + To: email, + Subject: fmt.Sprintf("Invitation to: %s", ev.Title), + Data: map[string]interface{}{ + "RSVP": rsvp, + "Event": ev, + "URL": strings.Trim(s.Site.URL, "/") + "/e/" + ev.Fragment, + }, + Template: ".email/event-invite.gohtml", + }) + } + + // An SMS number? + if sms != "" { + + } + + rsvp.Notified = true + rsvp.Save() +} diff --git a/internal/mail/mail.go b/internal/mail/mail.go index 13ba54f..52b9a64 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -10,9 +10,9 @@ import ( "github.com/kirsle/blog/internal/log" "github.com/kirsle/blog/internal/markdown" + "github.com/kirsle/blog/internal/render" "github.com/kirsle/blog/models/comments" "github.com/kirsle/blog/models/settings" - "github.com/kirsle/blog/internal/render" "github.com/microcosm-cc/bluemonday" gomail "gopkg.in/gomail.v2" ) @@ -32,9 +32,13 @@ type Email struct { // SendEmail sends an email. func SendEmail(email Email) { s, _ := settings.Load() + + // Suppress sending any mail when no mail settings are configured, but go + // through the motions -- great for local dev. + var doNotMail bool if !s.Mail.Enabled || s.Mail.Host == "" || s.Mail.Port == 0 || s.Mail.Sender == "" { log.Info("Suppressing email: not completely configured") - return + doNotMail = true } // Resolve the template. @@ -72,6 +76,13 @@ func SendEmail(email Email) { } plaintext := strings.Join(lines, "\n\n") + // If we're not actually going to send the mail, this is a good place to stop. + if doNotMail { + log.Info("Not going to send an email.") + log.Debug("The message was going to be:\n%s", plaintext) + return + } + m := gomail.NewMessage() m.SetHeader("From", fmt.Sprintf("%s <%s>", s.Site.Title, s.Mail.Sender)) m.SetHeader("To", email.To) diff --git a/models/contacts/contacts.go b/models/contacts/contacts.go index dd9b740..d85a9d6 100644 --- a/models/contacts/contacts.go +++ b/models/contacts/contacts.go @@ -2,17 +2,23 @@ package contacts import ( "errors" + "math/rand" "net/http" - "sort" "strings" "time" - "github.com/kirsle/blog/jsondb" + "github.com/jinzhu/gorm" "github.com/kirsle/golog" ) -// DB is a reference to the parent app's JsonDB object. -var DB *jsondb.DB +// DB is a reference to the parent app's gorm DB. +var DB *gorm.DB + +// UseDB registers the DB from the root app. +func UseDB(db *gorm.DB) { + DB = db + DB.AutoMigrate(&Contact{}) +} var log *golog.Logger @@ -20,15 +26,10 @@ 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"` + Secret string `json:"secret" gorm:"unique"` // their lazy insecure login token FirstName string `json:"firstName"` LastName string `json:"lastName"` Email string `json:"email"` @@ -38,79 +39,80 @@ type Contact struct { Updated time.Time `json:"updated"` } +// Contacts is the plurality of all contacts. +type Contacts []Contact + // NewContact initializes a new contact entry. -func NewContact() *Contact { - return &Contact{} +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++ - } - +// pre-save checks. +func (c *Contact) presave() { 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 + if c.Secret == "" { + // Make a random ID. + n := 8 + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + secret := make([]rune, n) + for i := range secret { + secret[i] = letters[rand.Intn(len(letters))] } + c.Secret = string(secret) } - return nil, errors.New("not found") +} + +// Add a new contact. +func Add(c *Contact) error { + c.presave() + + log.Error("contacts.Add: %+v", c) + + return DB.Create(&c).Error +} + +// All contacts from the database alphabetically sorted. +func All() (Contacts, error) { + result := Contacts{} + err := DB.Order("last_name").Find(&result).Error + return result, err +} + +// Get a contact by ID. +func Get(id int) (Contact, error) { + contact := Contact{} + err := DB.First(&contact, id).Error + return contact, err +} + +// Save the contact. +func (c Contact) Save() error { + c.presave() + return DB.Update(&c).Error } // 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") +func GetEmail(email string) (Contact, error) { + contact := Contact{} + err := DB.Where("email = ?", email).First(&contact).Error + return contact, err } // 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") +func GetSMS(number string) (Contact, error) { + contact := Contact{} + err := DB.Where("sms = ?", number).First(&contact).Error + return contact, err } // Name returns a friendly name for the contact. -func (c *Contact) Name() string { +func (c Contact) Name() string { var parts []string if c.FirstName != "" { parts = append(parts, c.FirstName) @@ -137,7 +139,7 @@ func (c *Contact) ParseForm(r *http.Request) { } // Validate the contact form. -func (c *Contact) Validate() error { +func (c Contact) Validate() error { if c.Email == "" && c.SMS == "" { return errors.New("email or sms number required") } @@ -145,17 +147,14 @@ func (c *Contact) Validate() error { 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 { + if _, err := GetEmail(c.Email); err == nil { return errors.New("email address already exists") } } if c.SMS != "" { - if _, err := addr.GetSMS(c.SMS); err == nil { + if _, err := GetSMS(c.SMS); err == nil { return errors.New("sms number already exists") } } diff --git a/models/events/events.go b/models/events/events.go index 51096aa..81472b8 100644 --- a/models/events/events.go +++ b/models/events/events.go @@ -9,12 +9,21 @@ import ( "strings" "time" - "github.com/kirsle/blog/jsondb" + "github.com/jinzhu/gorm" + "github.com/kirsle/blog/models/contacts" "github.com/kirsle/golog" ) -// DB is a reference to the parent app's JsonDB object. -var DB *jsondb.DB +// DB is a reference to the parent app's gorm DB. +var DB *gorm.DB + +// UseDB registers the DB from the root app. +func UseDB(db *gorm.DB) { + DB = db + DB.AutoMigrate(&Event{}, &RSVP{}) + DB.Model(&Event{}).Related(&RSVP{}) + DB.Model(&RSVP{}).Related(&contacts.Contact{}) +} var log *golog.Logger @@ -47,6 +56,13 @@ func New() *Event { } } +// All returns all the events. +func All() ([]*Event, error) { + result := []*Event{} + err := DB.Order("start_time desc").Find(&result).Error + return result, err +} + // ParseForm populates the event from form values. func (ev *Event) ParseForm(r *http.Request) { id, _ := strconv.Atoi(r.FormValue("id")) @@ -98,35 +114,27 @@ func (ev *Event) Validate() error { return nil } +// joinedLoad loads the Event with its RSVPs and their Contacts. +func joinedLoad() *gorm.DB { + return DB.Preload("RSVP").Preload("RSVP.Contact") +} + // Load an event by its ID. func Load(id int) (*Event, error) { ev := &Event{} - err := DB.Get(fmt.Sprintf("events/by-id/%d", id), &ev) + err := joinedLoad().First(ev, id).Error return ev, err } // LoadFragment loads an event by its URL fragment. func LoadFragment(fragment string) (*Event, error) { - idx, err := GetIndex() - if err != nil { - return nil, err - } - - if id, ok := idx.Fragments[fragment]; ok { - ev, err := Load(id) - return ev, err - } - - return nil, errors.New("fragment not found") + ev := &Event{} + err := joinedLoad().Where("fragment = ?", fragment).First(ev).Error + return ev, err } // Save the event. func (ev *Event) Save() error { - // Editing an existing event? - if ev.ID == 0 { - ev.ID = nextID() - } - // Generate a URL fragment if needed. if ev.Fragment == "" { fragment := strings.ToLower(ev.Title) @@ -173,15 +181,7 @@ func (ev *Event) Save() error { } // Write the event. - DB.Commit(fmt.Sprintf("events/by-id/%d", ev.ID), ev) - - // Update the index cache. - err := UpdateIndex(ev) - if err != nil { - return fmt.Errorf("UpdateIndex() error: %v", err) - } - - return nil + return DB.Save(&ev).Error } // Delete an event. @@ -191,38 +191,5 @@ func (ev *Event) Delete() error { } // Delete the DB files. - DB.Delete(fmt.Sprintf("events/by-id/%d", ev.ID)) - - // Remove it from the index. - idx, err := GetIndex() - if err != nil { - return fmt.Errorf("GetIndex error: %v", err) - } - return idx.Delete(ev) -} - -// getNextID gets the next blog post ID. -func nextID() int { - // Highest ID seen so far. - var highest int - - events, err := DB.List("events/by-id") - if err != nil { - return 1 - } - - for _, doc := range events { - fields := strings.Split(doc, "/") - id, err := strconv.Atoi(fields[len(fields)-1]) - if err != nil { - continue - } - - if id > highest { - highest = id - } - } - - // Return the highest +1 - return highest + 1 + return DB.Delete(ev).Error } diff --git a/models/events/index.go b/models/events/index.go deleted file mode 100644 index 1bfcc07..0000000 --- a/models/events/index.go +++ /dev/null @@ -1,60 +0,0 @@ -package events - -// Index maps URL fragments to event IDs. -type Index struct { - Fragments map[string]int `json:"fragments"` -} - -// GetIndex loads the index DB, or rebuilds it if not found. -func GetIndex() (*Index, error) { - if !DB.Exists("events/index") { - index, err := RebuildIndex() - return index, err - } - - idx := &Index{} - err := DB.Get("events/index", &idx) - return idx, err -} - -// RebuildIndex builds the event index from scratch. -func RebuildIndex() (*Index, error) { - idx := &Index{ - Fragments: map[string]int{}, - } - - events, _ := DB.List("events/by-id") - for _, doc := range events { - ev := &Event{} - err := DB.Get(doc, &ev) - if err != nil { - return nil, err - } - - idx.Update(ev) - } - - return idx, nil -} - -// UpdateIndex updates the index with an event. -func UpdateIndex(event *Event) error { - idx, err := GetIndex() - if err != nil { - return err - } - - return idx.Update(event) -} - -// Update an event in the index. -func (idx *Index) Update(event *Event) error { - idx.Fragments[event.Fragment] = event.ID - return DB.Commit("events/index", idx) -} - -// Delete an event from the index. -func (idx *Index) Delete(event *Event) error { - delete(idx.Fragments, event.Fragment) - return DB.Commit("events/index", idx) -} diff --git a/models/events/invites.go b/models/events/invites.go index f173107..bed1e7c 100644 --- a/models/events/invites.go +++ b/models/events/invites.go @@ -21,15 +21,47 @@ 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"` + ID int `json:"id"` + ContactID int `json:"contactId"` + EventID int `json:"eventId"` + Contact contacts.Contact `json:"-" gorm:"save_associations:false"` // 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"` +} + +// GetName of the user in the RSVP (from the contact or the anonymous name). +func (r RSVP) GetName() string { + if r.Contact.Name() != "" { + return r.Contact.Name() + } + return r.Name +} + +// GetEmail gets the user's email (from the contact or the anonymous email). +func (r RSVP) GetEmail() string { + if r.Contact.Email != "" { + return r.Contact.Email + } + return r.Email +} + +// GetSMS gets the user's SMS number (from the contact or the anonymous sms). +func (r RSVP) GetSMS() string { + if r.Contact.SMS != "" { + return r.Contact.SMS + } + return r.SMS +} + +// Save the RSVP. +func (r RSVP) Save() error { + r.Updated = time.Now().UTC() + return DB.Save(&r).Error } // InviteContactID enters an invitation for a contact ID. @@ -41,30 +73,24 @@ func (ev *Event) InviteContactID(id int) error { } } - ev.RSVP = append(ev.RSVP, RSVP{ + rsvp := &RSVP{ ContactID: id, + EventID: ev.ID, Status: StatusInvited, Created: time.Now().UTC(), Updated: time.Now().UTC(), - }) - return ev.Save() + } + return DB.Save(&rsvp).Error } -// 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) +// Uninvite removes an RSVP. +func (ev Event) Uninvite(id int) error { + var rsvp RSVP + err := DB.First(&rsvp, id).Error + if err != nil { + return err } - return result, nil + fmt.Printf("UNIVNITE: we have rsvp=%+v", rsvp) + return DB.Model(&ev).Association("RSVP").Delete(rsvp).Error } diff --git a/models/events/sorting.go b/models/events/sorting.go index e8ef7ac..a55bb42 100644 --- a/models/events/sorting.go +++ b/models/events/sorting.go @@ -8,3 +8,25 @@ 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[j].StartTime) || a[i].ID < a[j].ID } + +// ByName sorts RSVPs by name. +type ByName []RSVP + +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 { + var leftName, rightName string + if a[i].Contact.Name() != "" { + leftName = a[i].Contact.Name() + } else { + leftName = a[i].Name + } + + if a[j].Contact.Name() != "" { + rightName = a[j].Contact.Name() + } else { + rightName = a[j].Name + } + + return leftName < rightName +} diff --git a/root/.email/event-invite.gohtml b/root/.email/event-invite.gohtml new file mode 100644 index 0000000..845f790 --- /dev/null +++ b/root/.email/event-invite.gohtml @@ -0,0 +1,48 @@ + + + + + + + + {{ .Subject }} + + + +
+ + + + + + + + + + +
+ + {{ .Subject }} + +
+ + Dear {{ .Data.RSVP.GetName }}, +

+ + You have been invited to "{{ .Data.Event.Title }}" on {{ .Data.Event.StartTime.Format "January 1, 2006"}}! +

+ + To view the details and RSVP, visit the link below: +

+ + {{ .Data.URL }} +
+
+ + This e-mail was automatically generated; do not reply to it. + +
+
+ + + diff --git a/root/events/invite.gohtml b/root/events/invite.gohtml index 3679be5..c30d755 100644 --- a/root/events/invite.gohtml +++ b/root/events/invite.gohtml @@ -6,58 +6,73 @@

Invite {{ $e.Title }}

+

+ Back to Event Page +

+
Contact List
+

+ First, choose who you want to invite to your event. Adding them to the + "Invited" list does not immediately send them an e-mail; + the not notified badge means they + have yet to receive an e-mail or SMS message. +

+

+ To invite a new contact, scroll down to Invite New People. +

+

Invited

    - {{ range .Data.invited }} + {{ range $index, $rsvp := .Data.invited }}
  • - {{ if .Contact }} - {{ .Contact.Name }} +
    + + + + {{ if $rsvp.Contact }} + {{ $rsvp.Contact.Name }} {{ else }} - {{ .Name }} + {{ $rsvp.Name }} {{ end }} + +
      {{ if .Contact }} {{ if .Contact.Email }} -
    • - {{ .Contact.Email }} +
    • + {{ .Contact.Email }}
    • {{ end }} {{ if .Contact.SMS }} -
    • - {{ .Contact.SMS }} +
    • + {{ .Contact.SMS }}
    • {{ end }} {{ else }} {{ if .Email }} -
    • - {{ .Email }} +
    • + {{ .Email }}
    • {{ end }} {{ if .SMS }} -
    • - {{ .SMS }} +
    • + {{ .SMS }}
    • {{ end }} {{ end }}
    + {{ if not .Notified }} +
    not notified
    + {{ end }}
  • {{ end }} -
  • - John Doe
    - name@example.com -
  • -
  • - John Doe
    - name@example.com -
@@ -67,7 +82,7 @@
    - {{ range $cl.Contacts }} + {{ range $cl }} {{ if not (index $.Data.invitedMap .ID) }}
-
+
Invite New People
+

+ Fill in this form to create a new Contact and add them to the + Invited list above. +

+
@@ -165,13 +185,35 @@ + class="btn btn-success">Create Contact & Invite
+
+
Send Notifications
+
+
+ + + +

+ To send out notifications (e-mail and/or SMS) to all of the invited contacts, + click the button below. +

+

+ This will only notify contacts who have not yet received a notification. + That is, those with the not notified + badge above. +

+ + +
+
+
+

Invited

To Do diff --git a/root/events/view.gohtml b/root/events/view.gohtml index 7730e80..e69ba93 100644 --- a/root/events/view.gohtml +++ b/root/events/view.gohtml @@ -30,6 +30,57 @@ {{ end }}

Invited

+ +
+
    + {{ range .RSVP }} +
  • + {{ if .Contact }} + {{ .Contact.Name }} + {{ else }} + {{ .Name }} + {{ end }} +
    + {{ .Status }} + + {{ if and $.LoggedIn $.CurrentUser.Admin }} + {{ if not .Notified }} + not notified + {{ end }} + + {{ end }} +
  • + {{ end }} +
+
{{ end }}