SQLite DB, initial invite flows and UX

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

31
blog.go
View File

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

View File

@ -9,6 +9,7 @@ import (
"fmt"
"os"
_ "github.com/jinzhu/gorm/dialects/sqlite" // SQLite DB
"github.com/kirsle/blog"
"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.
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,
}

View File

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

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/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)

View File

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

View File

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

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,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
}

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 {
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>
<p>
<a href="/e/{{ $e.Fragment }}" class="btn btn-success">Back to Event Page</a>
</p>
<div class="card mb-4">
<div class="card-header">Contact List</div>
<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="col-6">
<h4>Invited</h4>
<ul class="list-unstyled">
{{ range .Data.invited }}
{{ range $index, $rsvp := .Data.invited }}
<li>
<div class="alert alert-info">
{{ if .Contact }}
<strong>{{ .Contact.Name }}</strong>
<form method="POST" action="/e/admin/invite/{{ $e.ID }}">
<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 }}
<strong>{{ .Name }}</strong>
<strong>{{ $rsvp.Name }}</strong>
{{ end }}
<button type="submit" class="btn btn-sm btn-danger">delete</button>
</form>
<ul class="list-inline">
{{ if .Contact }}
{{ if .Contact.Email }}
<li class="list-inline-item">
<a href="mailto:{{ .Contact.Email }}">{{ .Contact.Email }}</a>
<li class="list-inline-item text-muted">
{{ .Contact.Email }}
</li>
{{ end }}
{{ if .Contact.SMS }}
<li class="list-inline-item">
<a href="tel:{{ .Contact.SMS }}">{{ .Contact.SMS }}</a>
<li class="list-inline-item text-muted">
{{ .Contact.SMS }}
</li>
{{ end }}
{{ else }}
{{ if .Email }}
<li class="list-inline-item">
<a href="mailto:{{ .Email }}">{{ .Email }}</a>
<li class="list-inline-item text-muted">
{{ .Email }}
</li>
{{ end }}
{{ if .SMS }}
<li class="list-inline-item">
<a href="tel:{{ .SMS }}">{{ .SMS }}</a>
<li class="list-inline-item text-muted">
{{ .SMS }}
</li>
{{ end }}
{{ end }}
</ul>
{{ if not .Notified }}
<div class="badge badge-warning">not notified</div>
{{ end }}
</div>
</li>
{{ 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>
</div>
<div class="col-6">
@ -67,7 +82,7 @@
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
<ul class="list-unstyled">
{{ range $cl.Contacts }}
{{ range $cl }}
{{ if not (index $.Data.invitedMap .ID) }}
<li>
<label class="d-block alert alert-info">
@ -75,13 +90,13 @@
<strong>{{ .Name }}</strong>
<ul class="list-inline">
{{ if .Email }}
<li class="list-inline-item">
<a href="mailto:{{ .Email }}">{{ .Email }}</a>
<li class="list-inline-item text-muted">
{{ .Email }}
</li>
{{ end }}
{{ if .SMS }}
<li class="list-inline-item">
<a href="tel:{{ .SMS }}">{{ .SMS }}</a>
<li class="list-inline-item text-muted">
{{ .SMS }}
</li>
{{ end }}
</ul>
@ -107,7 +122,7 @@
<button type="submit"
name="action" value="send-invite"
class="btn btn-primary">Send Invites</button>
class="btn btn-primary">Invite Contact</button>
<a href="/admin/contacts"
class="btn btn-secondary">Manage Contacts</a>
@ -117,12 +132,17 @@
</div>
</div>
<div class="card">
<div class="card mb-4" id="new-contact">
<div class="card-header">Invite New People</div>
<div class="card-body">
<form action="/e/admin/invite/{{ $e.ID }}" method="POST">
<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-group col-md-6">
<label for="first_name">First name:</label>
@ -165,13 +185,35 @@
<button type="submit"
name="action"
value="new-contact"
class="btn btn-primary">Send Invite</button>
class="btn btn-success">Create Contact & Invite</button>
</div>
</form>
</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>
To Do

View File

@ -30,6 +30,57 @@
{{ end }}
<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>
{{ end }}