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"
|
||||
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite" // SQLite DB
|
||||
"github.com/kirsle/blog"
|
||||
"github.com/kirsle/blog/jsondb"
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
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/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)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
// 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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
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>
|
||||
|
||||
<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
|
||||
|
|
|
@ -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 }}
|
||||
|
|
Loading…
Reference in New Issue
Block a user