SQLite DB, initial invite flows and UX
This commit is contained in:
parent
345878fabe
commit
a166c72cf3
31
blog.go
31
blog.go
|
@ -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,8 +45,9 @@ type Blog struct {
|
||||||
DocumentRoot string
|
DocumentRoot string
|
||||||
UserRoot string
|
UserRoot string
|
||||||
|
|
||||||
DB *jsondb.DB
|
db *gorm.DB
|
||||||
Cache caches.Cacher
|
jsonDB *jsondb.DB
|
||||||
|
Cache caches.Cacher
|
||||||
|
|
||||||
// Web app objects.
|
// Web app objects.
|
||||||
n *negroni.Negroni // Negroni middleware manager
|
n *negroni.Negroni // Negroni middleware manager
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
@ -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")
|
if err != nil {
|
||||||
for _, doc := range docs {
|
log.Error("error listing all events: %s", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
40
internal/controllers/events/notifier.go
Normal file
40
internal/controllers/events/notifier.go
Normal 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()
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
// pre-save checks.
|
||||||
func Load() (*Contacts, error) {
|
func (c *Contact) presave() {
|
||||||
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() {
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the contact list.
|
if c.Secret == "" {
|
||||||
func (cl *Contacts) Save() error {
|
// Make a random ID.
|
||||||
sort.Sort(ByName(cl.Contacts))
|
n := 8
|
||||||
return DB.Commit("contacts/address-book", cl)
|
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
}
|
secret := make([]rune, n)
|
||||||
|
for i := range secret {
|
||||||
// GetID queries a contact by its ID number.
|
secret[i] = letters[rand.Intn(len(letters))]
|
||||||
func (cl *Contacts) GetID(id int) (*Contact, error) {
|
|
||||||
for _, c := range cl.Contacts {
|
|
||||||
if c.ID == id {
|
|
||||||
return c, nil
|
|
||||||
}
|
}
|
||||||
|
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.
|
// 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
return ev, err
|
||||||
}
|
|
||||||
|
|
||||||
if id, ok := idx.Fragments[fragment]; ok {
|
|
||||||
ev, err := Load(id)
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -21,15 +21,47 @@ 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.
|
||||||
ContactID int `json:"contactId"`
|
ID int `json:"id"`
|
||||||
Contact *contacts.Contact `json:"-"` // rel table not serialized to JSON
|
ContactID int `json:"contactId"`
|
||||||
Status string `json:"status"` // invited, going, maybe, not going
|
EventID int `json:"eventId"`
|
||||||
Notified bool `json:"notified"`
|
Contact contacts.Contact `json:"-" gorm:"save_associations:false"` // rel table not serialized to JSON
|
||||||
Name string `json:"name,omitempty"`
|
Status string `json:"status"` // invited, going, maybe, not going
|
||||||
Email string `json:"email,omitempty"`
|
Notified bool `json:"notified"`
|
||||||
SMS string `json:"sms,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Created time.Time `json:"created"`
|
Email string `json:"email,omitempty"`
|
||||||
Updated time.Time `json:"updated"`
|
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.
|
// 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,
|
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 err != nil {
|
||||||
if rsvp.ContactID != 0 {
|
return err
|
||||||
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
|
fmt.Printf("UNIVNITE: we have rsvp=%+v", rsvp)
|
||||||
|
return DB.Model(&ev).Association("RSVP").Delete(rsvp).Error
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
48
root/.email/event-invite.gohtml
Normal file
48
root/.email/event-invite.gohtml
Normal 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>
|
|
@ -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
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user