SQLite DB, initial invite flows and UX

This commit is contained in:
Noah 2018-05-11 20:15:16 -07:00
parent 345878fabe
commit a166c72cf3
14 changed files with 456 additions and 278 deletions

29
blog.go
View File

@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/jinzhu/gorm"
"github.com/kirsle/blog/internal/controllers/admin" "github.com/kirsle/blog/internal/controllers/admin"
"github.com/kirsle/blog/internal/controllers/authctl" "github.com/kirsle/blog/internal/controllers/authctl"
commentctl "github.com/kirsle/blog/internal/controllers/comments" commentctl "github.com/kirsle/blog/internal/controllers/comments"
@ -44,7 +45,8 @@ type Blog struct {
DocumentRoot string DocumentRoot string
UserRoot string UserRoot string
DB *jsondb.DB db *gorm.DB
jsonDB *jsondb.DB
Cache caches.Cacher Cache caches.Cacher
// Web app objects. // Web app objects.
@ -54,10 +56,16 @@ type Blog struct {
// New initializes the Blog application. // New initializes the Blog application.
func New(documentRoot, userRoot string) *Blog { func New(documentRoot, userRoot string) *Blog {
db, err := gorm.Open("sqlite3", filepath.Join(userRoot, ".private", "database.sqlite"))
if err != nil {
panic(err)
}
return &Blog{ return &Blog{
DocumentRoot: documentRoot, DocumentRoot: documentRoot,
UserRoot: userRoot, UserRoot: userRoot,
DB: jsondb.New(filepath.Join(userRoot, ".private")), db: db,
jsonDB: jsondb.New(filepath.Join(userRoot, ".private")),
Cache: null.New(), Cache: null.New(),
} }
} }
@ -72,8 +80,11 @@ func (b *Blog) Run(address string) {
// Configure initializes (or reloads) the blog's configuration, and binds the // Configure initializes (or reloads) the blog's configuration, and binds the
// settings in sub-packages. // settings in sub-packages.
func (b *Blog) Configure() { func (b *Blog) Configure() {
if b.Debug {
b.db.LogMode(true)
}
// Load the site config, or start with defaults if not found. // Load the site config, or start with defaults if not found.
settings.DB = b.DB settings.DB = b.jsonDB
config, err := settings.Load() config, err := settings.Load()
if err != nil { if err != nil {
config = settings.Defaults() config = settings.Defaults()
@ -88,11 +99,11 @@ func (b *Blog) Configure() {
users.HashCost = config.Security.HashCost users.HashCost = config.Security.HashCost
// Initialize the rest of the models. // Initialize the rest of the models.
posts.DB = b.DB contacts.UseDB(b.db)
users.DB = b.DB events.UseDB(b.db)
comments.DB = b.DB posts.DB = b.jsonDB
contacts.DB = b.DB users.DB = b.jsonDB
events.DB = b.DB comments.DB = b.jsonDB
// Redis cache? // Redis cache?
if config.Redis.Enabled { if config.Redis.Enabled {
@ -107,7 +118,7 @@ func (b *Blog) Configure() {
log.Error("Redis init error: %s", err.Error()) log.Error("Redis init error: %s", err.Error())
} else { } else {
b.Cache = cache b.Cache = cache
b.DB.Cache = cache b.jsonDB.Cache = cache
markdown.Cache = cache markdown.Cache = cache
} }
} }

View File

@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"os" "os"
_ "github.com/jinzhu/gorm/dialects/sqlite" // SQLite DB
"github.com/kirsle/blog" "github.com/kirsle/blog"
"github.com/kirsle/blog/jsondb" "github.com/kirsle/blog/jsondb"
) )

View File

@ -33,17 +33,9 @@ func Register(r *mux.Router, loginError http.HandlerFunc) {
// Admin index to view all events. // Admin index to view all events.
func indexHandler(w http.ResponseWriter, r *http.Request) { func indexHandler(w http.ResponseWriter, r *http.Request) {
result := []*events.Event{} result, err := events.All()
docs, _ := events.DB.List("events/by-id")
for _, doc := range docs {
ev := &events.Event{}
err := events.DB.Get(doc, &ev)
if err != nil { if err != nil {
log.Error("error reading %s: %s", doc, err) log.Error("error listing all events: %s", err)
continue
}
result = append(result, ev)
} }
sort.Sort(sort.Reverse(events.ByDate(result))) sort.Sort(sort.Reverse(events.ByDate(result)))
@ -68,6 +60,8 @@ func viewHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
sort.Sort(events.ByName(event.RSVP))
v := map[string]interface{}{ v := map[string]interface{}{
"event": event, "event": event,
} }

View File

@ -28,9 +28,6 @@ func inviteHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get the address book.
addr, _ := contacts.Load()
// Handle POST requests. // Handle POST requests.
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
action := r.FormValue("action") action := r.FormValue("action")
@ -45,14 +42,18 @@ func inviteHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
addr.Add(c) err = contacts.Add(&c)
err = addr.Save()
if err != nil { if err != nil {
responses.FlashAndReload(w, r, "Error when saving address book: %s", err) responses.FlashAndReload(w, r, "Error when saving address book: %s", err)
return 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 return
case "send-invite": case "send-invite":
log.Error("Send Invite!") log.Error("Send Invite!")
@ -67,7 +68,8 @@ func inviteHandler(w http.ResponseWriter, r *http.Request) {
var warnings []string var warnings []string
for _, strID := range contactIDs { for _, strID := range contactIDs {
id, _ := strconv.Atoi(strID) 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 { if err != nil {
warnings = append(warnings, err.Error()) warnings = append(warnings, err.Error())
} }
@ -77,10 +79,29 @@ func inviteHandler(w http.ResponseWriter, r *http.Request) {
} }
responses.FlashAndReload(w, r, "Invites sent!") responses.FlashAndReload(w, r, "Invites sent!")
return 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 { if err != nil {
log.Error("error getting event.Invited: %s", err) 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{}{ v := map[string]interface{}{
"event": event, "event": event,
"invited": invited, "invited": invited,
"invitedMap": invitedMap, "invitedMap": invitedMap,
"contacts": addr, "contacts": allContacts,
} }
render.Template(w, r, "events/invite", v) render.Template(w, r, "events/invite", v)
} }

View File

@ -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()
}

View File

@ -10,9 +10,9 @@ import (
"github.com/kirsle/blog/internal/log" "github.com/kirsle/blog/internal/log"
"github.com/kirsle/blog/internal/markdown" "github.com/kirsle/blog/internal/markdown"
"github.com/kirsle/blog/internal/render"
"github.com/kirsle/blog/models/comments" "github.com/kirsle/blog/models/comments"
"github.com/kirsle/blog/models/settings" "github.com/kirsle/blog/models/settings"
"github.com/kirsle/blog/internal/render"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
gomail "gopkg.in/gomail.v2" gomail "gopkg.in/gomail.v2"
) )
@ -32,9 +32,13 @@ type Email struct {
// SendEmail sends an email. // SendEmail sends an email.
func SendEmail(email Email) { func SendEmail(email Email) {
s, _ := settings.Load() 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 == "" { if !s.Mail.Enabled || s.Mail.Host == "" || s.Mail.Port == 0 || s.Mail.Sender == "" {
log.Info("Suppressing email: not completely configured") log.Info("Suppressing email: not completely configured")
return doNotMail = true
} }
// Resolve the template. // Resolve the template.
@ -72,6 +76,13 @@ func SendEmail(email Email) {
} }
plaintext := strings.Join(lines, "\n\n") 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 := gomail.NewMessage()
m.SetHeader("From", fmt.Sprintf("%s <%s>", s.Site.Title, s.Mail.Sender)) m.SetHeader("From", fmt.Sprintf("%s <%s>", s.Site.Title, s.Mail.Sender))
m.SetHeader("To", email.To) m.SetHeader("To", email.To)

View File

@ -2,17 +2,23 @@ package contacts
import ( import (
"errors" "errors"
"math/rand"
"net/http" "net/http"
"sort"
"strings" "strings"
"time" "time"
"github.com/kirsle/blog/jsondb" "github.com/jinzhu/gorm"
"github.com/kirsle/golog" "github.com/kirsle/golog"
) )
// DB is a reference to the parent app's JsonDB object. // DB is a reference to the parent app's gorm DB.
var DB *jsondb.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 var log *golog.Logger
@ -20,15 +26,10 @@ func init() {
log = golog.GetLogger("blog") 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. // Contact is an individual contact in the address book.
type Contact struct { type Contact struct {
ID int `json:"id"` ID int `json:"id"`
Secret string `json:"secret" gorm:"unique"` // their lazy insecure login token
FirstName string `json:"firstName"` FirstName string `json:"firstName"`
LastName string `json:"lastName"` LastName string `json:"lastName"`
Email string `json:"email"` Email string `json:"email"`
@ -38,79 +39,80 @@ type Contact struct {
Updated time.Time `json:"updated"` Updated time.Time `json:"updated"`
} }
// Contacts is the plurality of all contacts.
type Contacts []Contact
// NewContact initializes a new contact entry. // NewContact initializes a new contact entry.
func NewContact() *Contact { func NewContact() Contact {
return &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() { if c.Created.IsZero() {
c.Created = time.Now().UTC() c.Created = time.Now().UTC()
} }
if c.Updated.IsZero() { if c.Updated.IsZero() {
c.Updated = time.Now().UTC() c.Updated = time.Now().UTC()
} }
cl.Contacts = append(cl.Contacts, c)
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)
}
} }
// Save the contact list. // Add a new contact.
func (cl *Contacts) Save() error { func Add(c *Contact) error {
sort.Sort(ByName(cl.Contacts)) c.presave()
return DB.Commit("contacts/address-book", cl)
log.Error("contacts.Add: %+v", c)
return DB.Create(&c).Error
} }
// GetID queries a contact by its ID number. // All contacts from the database alphabetically sorted.
func (cl *Contacts) GetID(id int) (*Contact, error) { func All() (Contacts, error) {
for _, c := range cl.Contacts { result := Contacts{}
if c.ID == id { err := DB.Order("last_name").Find(&result).Error
return c, nil 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
} }
return nil, errors.New("not found")
// Save the contact.
func (c Contact) Save() error {
c.presave()
return DB.Update(&c).Error
} }
// GetEmail queries a contact by email address. // GetEmail queries a contact by email address.
func (cl *Contacts) GetEmail(email string) (*Contact, error) { func GetEmail(email string) (Contact, error) {
email = strings.ToLower(email) contact := Contact{}
for _, c := range cl.Contacts { err := DB.Where("email = ?", email).First(&contact).Error
if c.Email == email { return contact, err
return c, nil
}
}
return nil, errors.New("not found")
} }
// GetSMS queries a contact by SMS number. // GetSMS queries a contact by SMS number.
func (cl *Contacts) GetSMS(number string) (*Contact, error) { func GetSMS(number string) (Contact, error) {
for _, c := range cl.Contacts { contact := Contact{}
if c.SMS == number { err := DB.Where("sms = ?", number).First(&contact).Error
return c, nil return contact, err
}
}
return nil, errors.New("not found")
} }
// Name returns a friendly name for the contact. // Name returns a friendly name for the contact.
func (c *Contact) Name() string { func (c Contact) Name() string {
var parts []string var parts []string
if c.FirstName != "" { if c.FirstName != "" {
parts = append(parts, c.FirstName) parts = append(parts, c.FirstName)
@ -137,7 +139,7 @@ func (c *Contact) ParseForm(r *http.Request) {
} }
// Validate the contact form. // Validate the contact form.
func (c *Contact) Validate() error { func (c Contact) Validate() error {
if c.Email == "" && c.SMS == "" { if c.Email == "" && c.SMS == "" {
return errors.New("email or sms number required") 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") return errors.New("first or last name required")
} }
// Get the address book out.
addr, _ := Load()
// Check for uniqueness of email and SMS. // Check for uniqueness of email and SMS.
if c.Email != "" { 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") return errors.New("email address already exists")
} }
} }
if c.SMS != "" { 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") return errors.New("sms number already exists")
} }
} }

View File

@ -9,12 +9,21 @@ import (
"strings" "strings"
"time" "time"
"github.com/kirsle/blog/jsondb" "github.com/jinzhu/gorm"
"github.com/kirsle/blog/models/contacts"
"github.com/kirsle/golog" "github.com/kirsle/golog"
) )
// DB is a reference to the parent app's JsonDB object. // DB is a reference to the parent app's gorm DB.
var DB *jsondb.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 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. // ParseForm populates the event from form values.
func (ev *Event) ParseForm(r *http.Request) { func (ev *Event) ParseForm(r *http.Request) {
id, _ := strconv.Atoi(r.FormValue("id")) id, _ := strconv.Atoi(r.FormValue("id"))
@ -98,35 +114,27 @@ func (ev *Event) Validate() error {
return nil 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. // Load an event by its ID.
func Load(id int) (*Event, error) { func Load(id int) (*Event, error) {
ev := &Event{} ev := &Event{}
err := DB.Get(fmt.Sprintf("events/by-id/%d", id), &ev) err := joinedLoad().First(ev, id).Error
return ev, err return ev, err
} }
// LoadFragment loads an event by its URL fragment. // LoadFragment loads an event by its URL fragment.
func LoadFragment(fragment string) (*Event, error) { func LoadFragment(fragment string) (*Event, error) {
idx, err := GetIndex() ev := &Event{}
if err != nil { err := joinedLoad().Where("fragment = ?", fragment).First(ev).Error
return nil, err
}
if id, ok := idx.Fragments[fragment]; ok {
ev, err := Load(id)
return ev, err return ev, err
} }
return nil, errors.New("fragment not found")
}
// Save the event. // Save the event.
func (ev *Event) Save() error { func (ev *Event) Save() error {
// Editing an existing event?
if ev.ID == 0 {
ev.ID = nextID()
}
// Generate a URL fragment if needed. // Generate a URL fragment if needed.
if ev.Fragment == "" { if ev.Fragment == "" {
fragment := strings.ToLower(ev.Title) fragment := strings.ToLower(ev.Title)
@ -173,15 +181,7 @@ func (ev *Event) Save() error {
} }
// Write the event. // Write the event.
DB.Commit(fmt.Sprintf("events/by-id/%d", ev.ID), ev) return DB.Save(&ev).Error
// Update the index cache.
err := UpdateIndex(ev)
if err != nil {
return fmt.Errorf("UpdateIndex() error: %v", err)
}
return nil
} }
// Delete an event. // Delete an event.
@ -191,38 +191,5 @@ func (ev *Event) Delete() error {
} }
// Delete the DB files. // Delete the DB files.
DB.Delete(fmt.Sprintf("events/by-id/%d", ev.ID)) return DB.Delete(ev).Error
// 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
} }

View File

@ -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)
}

View File

@ -21,8 +21,10 @@ type RSVP struct {
// If the user was invited by an admin, they will have a ContactID and // 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 // not much else. Users who signed up themselves from an OpenSignup event
// will have the metadata filled in instead. // will have the metadata filled in instead.
ID int `json:"id"`
ContactID int `json:"contactId"` ContactID int `json:"contactId"`
Contact *contacts.Contact `json:"-"` // rel table not serialized to JSON 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 Status string `json:"status"` // invited, going, maybe, not going
Notified bool `json:"notified"` Notified bool `json:"notified"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@ -32,6 +34,36 @@ type RSVP struct {
Updated time.Time `json:"updated"` 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. // InviteContactID enters an invitation for a contact ID.
func (ev *Event) InviteContactID(id int) error { func (ev *Event) InviteContactID(id int) error {
// Make sure the ID isn't already in the list. // Make sure the ID isn't already in the list.
@ -41,30 +73,24 @@ func (ev *Event) InviteContactID(id int) error {
} }
} }
ev.RSVP = append(ev.RSVP, RSVP{ rsvp := &RSVP{
ContactID: id, ContactID: id,
EventID: ev.ID,
Status: StatusInvited, Status: StatusInvited,
Created: time.Now().UTC(), Created: time.Now().UTC(),
Updated: 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. // Uninvite removes an RSVP.
func (ev *Event) Invited() ([]RSVP, error) { func (ev Event) Uninvite(id int) error {
cl, _ := contacts.Load() var rsvp RSVP
result := []RSVP{} err := DB.First(&rsvp, id).Error
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 { if err != nil {
fmt.Printf("event.Invited error: %s", err) return err
}
rsvp.Contact = c
}
result = append(result, rsvp)
} }
return result, nil fmt.Printf("UNIVNITE: we have rsvp=%+v", rsvp)
return DB.Model(&ev).Association("RSVP").Delete(rsvp).Error
} }

View File

@ -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 { func (a ByDate) Less(i, j int) bool {
return a[i].StartTime.Before(a[j].StartTime) || a[i].ID < a[j].ID 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
}

View File

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting"><!-- Disable auto-scale in iOS 10 Mail -->
<title>{{ .Subject }}</title>
</head>
<body width="100%" bgcolor="#FFFFFF" color="#000000" style="margin: 0; mso-line-height-rule: exactly;">
<center>
<table width="90%" cellspacing="0" cellpadding="8" style="border: 1px solid #000000">
<tr>
<td align="left" valign="top" bgcolor="#C0C0C0">
<font face="Helvetica,Arial,Verdana-sans-serif" size="6" color="#000000">
<b>{{ .Subject }}</b>
</font>
</td>
</tr>
<tr>
<td align="left" valign="top" bgcolor="#FEFEFE">
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
Dear {{ .Data.RSVP.GetName }},
<br><br>
You have been invited to "{{ .Data.Event.Title }}" on {{ .Data.Event.StartTime.Format "January 1, 2006"}}!
<br><br>
To view the details and RSVP, visit the link below:
<br><br>
<a href="{{ .Data.URL }}" target="_blank">{{ .Data.URL }}</a>
</font>
</td>
</tr>
<tr>
<td align="left" valign="top" bgcolor="#C0C0C0">
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
This e-mail was automatically generated; do not reply to it.
</font>
</td>
</tr>
</table>
</center>
</body>
</html>

View File

@ -6,58 +6,73 @@
<h1>Invite <em>{{ $e.Title }}</em></h1> <h1>Invite <em>{{ $e.Title }}</em></h1>
<p>
<a href="/e/{{ $e.Fragment }}" class="btn btn-success">Back to Event Page</a>
</p>
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header">Contact List</div> <div class="card-header">Contact List</div>
<div class="card-body"> <div class="card-body">
<p>
First, choose who you want to invite to your event. Adding them to the
"Invited" list does <strong>not</strong> immediately send them an e-mail;
the <span class="badge badge-warning">not notified</span> badge means they
have <em>yet</em> to receive an e-mail or SMS message.
</p>
<p>
To invite a <em>new</em> contact, scroll down to <a href="#new-contact">Invite New People</a>.
</p>
<div class="row" style="max-height: 500px; overflow: auto"> <div class="row" style="max-height: 500px; overflow: auto">
<div class="col-6"> <div class="col-6">
<h4>Invited</h4> <h4>Invited</h4>
<ul class="list-unstyled"> <ul class="list-unstyled">
{{ range .Data.invited }} {{ range $index, $rsvp := .Data.invited }}
<li> <li>
<div class="alert alert-info"> <div class="alert alert-info">
{{ if .Contact }} <form method="POST" action="/e/admin/invite/{{ $e.ID }}">
<strong>{{ .Contact.Name }}</strong> <input type="hidden" name="_csrf" value="{{ $.CSRF }}">
<input type="hidden" name="action" value="revoke-invite">
<input type="hidden" name="index" value="{{ $rsvp.ID }}">
{{ if $rsvp.Contact }}
<strong>{{ $rsvp.Contact.Name }}</strong>
{{ else }} {{ else }}
<strong>{{ .Name }}</strong> <strong>{{ $rsvp.Name }}</strong>
{{ end }} {{ end }}
<button type="submit" class="btn btn-sm btn-danger">delete</button>
</form>
<ul class="list-inline"> <ul class="list-inline">
{{ if .Contact }} {{ if .Contact }}
{{ if .Contact.Email }} {{ if .Contact.Email }}
<li class="list-inline-item"> <li class="list-inline-item text-muted">
<a href="mailto:{{ .Contact.Email }}">{{ .Contact.Email }}</a> {{ .Contact.Email }}
</li> </li>
{{ end }} {{ end }}
{{ if .Contact.SMS }} {{ if .Contact.SMS }}
<li class="list-inline-item"> <li class="list-inline-item text-muted">
<a href="tel:{{ .Contact.SMS }}">{{ .Contact.SMS }}</a> {{ .Contact.SMS }}
</li> </li>
{{ end }} {{ end }}
{{ else }} {{ else }}
{{ if .Email }} {{ if .Email }}
<li class="list-inline-item"> <li class="list-inline-item text-muted">
<a href="mailto:{{ .Email }}">{{ .Email }}</a> {{ .Email }}
</li> </li>
{{ end }} {{ end }}
{{ if .SMS }} {{ if .SMS }}
<li class="list-inline-item"> <li class="list-inline-item text-muted">
<a href="tel:{{ .SMS }}">{{ .SMS }}</a> {{ .SMS }}
</li> </li>
{{ end }} {{ end }}
{{ end }} {{ end }}
</ul> </ul>
{{ if not .Notified }}
<div class="badge badge-warning">not notified</div>
{{ end }}
</div> </div>
</li> </li>
{{ end }} {{ end }}
<li>
<strong>John Doe</strong><br>
<span class="text-muted">name@example.com</span>
</li>
<li>
<strong>John Doe</strong><br>
<span class="text-muted">name@example.com</span>
</li>
</ul> </ul>
</div> </div>
<div class="col-6"> <div class="col-6">
@ -67,7 +82,7 @@
<input type="hidden" name="_csrf" value="{{ .CSRF }}"> <input type="hidden" name="_csrf" value="{{ .CSRF }}">
<ul class="list-unstyled"> <ul class="list-unstyled">
{{ range $cl.Contacts }} {{ range $cl }}
{{ if not (index $.Data.invitedMap .ID) }} {{ if not (index $.Data.invitedMap .ID) }}
<li> <li>
<label class="d-block alert alert-info"> <label class="d-block alert alert-info">
@ -75,13 +90,13 @@
<strong>{{ .Name }}</strong> <strong>{{ .Name }}</strong>
<ul class="list-inline"> <ul class="list-inline">
{{ if .Email }} {{ if .Email }}
<li class="list-inline-item"> <li class="list-inline-item text-muted">
<a href="mailto:{{ .Email }}">{{ .Email }}</a> {{ .Email }}
</li> </li>
{{ end }} {{ end }}
{{ if .SMS }} {{ if .SMS }}
<li class="list-inline-item"> <li class="list-inline-item text-muted">
<a href="tel:{{ .SMS }}">{{ .SMS }}</a> {{ .SMS }}
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
@ -107,7 +122,7 @@
<button type="submit" <button type="submit"
name="action" value="send-invite" name="action" value="send-invite"
class="btn btn-primary">Send Invites</button> class="btn btn-primary">Invite Contact</button>
<a href="/admin/contacts" <a href="/admin/contacts"
class="btn btn-secondary">Manage Contacts</a> class="btn btn-secondary">Manage Contacts</a>
@ -117,12 +132,17 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card mb-4" id="new-contact">
<div class="card-header">Invite New People</div> <div class="card-header">Invite New People</div>
<div class="card-body"> <div class="card-body">
<form action="/e/admin/invite/{{ $e.ID }}" method="POST"> <form action="/e/admin/invite/{{ $e.ID }}" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}"> <input type="hidden" name="_csrf" value="{{ .CSRF }}">
<p>
Fill in this form to create a new Contact and add them to the
Invited list above.
</p>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-6"> <div class="form-group col-md-6">
<label for="first_name">First name:</label> <label for="first_name">First name:</label>
@ -165,13 +185,35 @@
<button type="submit" <button type="submit"
name="action" name="action"
value="new-contact" value="new-contact"
class="btn btn-primary">Send Invite</button> class="btn btn-success">Create Contact & Invite</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<div class="card mb-4">
<div class="card-header">Send Notifications</div>
<div class="card-body">
<form method="POST" action="/e/admin/invite/{{ $e.ID }}">
<input type="hidden" name="_csrf" value="{{ $.CSRF }}">
<input type="hidden" name="action" value="notify">
<p>
To send out notifications (e-mail and/or SMS) to all of the invited contacts,
click the button below.
</p>
<p>
This will only notify contacts who have not yet received a notification.
That is, those with the <span class="badge badge-warning">not notified</span>
badge above.
</p>
<button type="submit" class="btn btn-danger">Notify Invited Contacts</button>
</form>
</div>
</div>
<h2>Invited</h2> <h2>Invited</h2>
To Do To Do

View File

@ -30,6 +30,57 @@
{{ end }} {{ end }}
<h4 class="mt-4">Invited</h4> <h4 class="mt-4">Invited</h4>
<div style="max-height: 500px; overflow: auto">
<ul class="list-group">
{{ range .RSVP }}
<li class="list-group-item
{{ if eq .Status "invited" }}bg-light
{{ else if eq .Status "going"}}bg-success text-light
{{ else if eq .Status "not going"}}bg-danger text-light
{{ else if eq .Status "maybe"}}bg-warning text-light{{ end }}">
{{ if .Contact }}
<strong>{{ .Contact.Name }}</strong>
{{ else }}
<strong>{{ .Name }}</strong>
{{ end }}
<br>
{{ .Status }}
{{ if and $.LoggedIn $.CurrentUser.Admin }}
{{ if not .Notified }}
<span class="badge badge-warning">not notified</span>
{{ end }}
<ul class="list-inline">
{{ if .Contact }}
{{ if .Contact.Email }}
<li class="list-inline-item text-muted">
<a href="mailto:{{ .Contact.Email }}">{{ .Contact.Email }}</a>
</li>
{{ end }}
{{ if .Contact.SMS }}
<li class="list-inline-item text-muted">
<a href="tel:{{ .Contact.SMS }}">{{ .Contact.SMS }}</a>
</li>
{{ end }}
{{ else }}
{{ if .Email }}
<li class="list-inline-item text-muted">
<a href="mailto:{{ .Email }}">{{ .Email }}</a>
</li>
{{ end }}
{{ if .SMS }}
<li class="list-inline-item text-muted">
<a href="tel:{{ .SMS }}">{{ .SMS }}</a>
</li>
{{ end }}
{{ end }}
</ul>
{{ end }}
</li>
{{ end }}
</ul>
</div>
</div> </div>
</div> </div>
{{ end }} {{ end }}