Contact creation and invite UI

events
Noah 2018-04-30 17:50:39 -07:00
parent 765e80b64d
commit 345878fabe
10 changed files with 488 additions and 113 deletions

View File

@ -26,6 +26,7 @@ import (
"github.com/kirsle/blog/jsondb/caches/null"
"github.com/kirsle/blog/jsondb/caches/redis"
"github.com/kirsle/blog/models/comments"
"github.com/kirsle/blog/models/contacts"
"github.com/kirsle/blog/models/events"
"github.com/kirsle/blog/models/posts"
"github.com/kirsle/blog/models/settings"
@ -90,6 +91,7 @@ func (b *Blog) Configure() {
posts.DB = b.DB
users.DB = b.DB
comments.DB = b.DB
contacts.DB = b.DB
events.DB = b.DB
// Redis cache?

View File

@ -18,7 +18,7 @@ func Register(r *mux.Router, loginError http.HandlerFunc) {
// Login-required routers.
loginRouter := mux.NewRouter()
loginRouter.HandleFunc("/e/admin/edit", editHandler)
loginRouter.HandleFunc("/e/admin/invite", inviteHandler)
loginRouter.HandleFunc("/e/admin/invite/{id}", inviteHandler)
loginRouter.HandleFunc("/e/admin/", indexHandler)
r.PathPrefix("/e/admin").Handler(
negroni.New(

View File

@ -3,28 +3,101 @@ package events
import (
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/kirsle/blog/internal/log"
"github.com/kirsle/blog/internal/render"
"github.com/kirsle/blog/internal/responses"
"github.com/kirsle/blog/models/contacts"
"github.com/kirsle/blog/models/events"
)
func inviteHandler(w http.ResponseWriter, r *http.Request) {
v := map[string]interface{}{
"preview": "",
}
id, err := strconv.Atoi(r.FormValue("id"))
params := mux.Vars(r)
id, err := strconv.Atoi(params["id"])
if err != nil {
responses.FlashAndRedirect(w, r, "/e/admin/", "Invalid ID")
return
}
// Load the event from its ID.
event, err := events.Load(id)
if err != nil {
responses.FlashAndRedirect(w, r, "/e/admin/", "Can't load event: %s", err)
return
}
v["event"] = event
// Get the address book.
addr, _ := contacts.Load()
// Handle POST requests.
if r.Method == http.MethodPost {
action := r.FormValue("action")
switch action {
case "new-contact":
c := contacts.NewContact()
c.ParseForm(r)
err = c.Validate()
if err != nil {
responses.FlashAndReload(w, r, "Validation error: %s", err)
return
}
addr.Add(c)
err = addr.Save()
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())
return
case "send-invite":
log.Error("Send Invite!")
r.ParseForm()
contactIDs, ok := r.Form["invite"]
if !ok {
responses.Error(w, r, "Missing: invite (list of IDs)")
return
}
// Invite all the users.
var warnings []string
for _, strID := range contactIDs {
id, _ := strconv.Atoi(strID)
err := event.InviteContactID(id)
if err != nil {
warnings = append(warnings, err.Error())
}
}
if len(warnings) > 0 {
responses.Flash(w, r, "Warnings: %s", strings.Join(warnings, "; "))
}
responses.FlashAndReload(w, r, "Invites sent!")
return
}
}
invited, err := event.Invited()
if err != nil {
log.Error("error getting event.Invited: %s", err)
}
// Map the invited user IDs.
invitedMap := map[int]bool{}
for _, rsvp := range invited {
if rsvp.ContactID != 0 {
invitedMap[rsvp.ContactID] = true
}
}
v := map[string]interface{}{
"event": event,
"invited": invited,
"invitedMap": invitedMap,
"contacts": addr,
}
render.Template(w, r, "events/invite", v)
}

164
models/contacts/contacts.go Normal file
View File

@ -0,0 +1,164 @@
package contacts
import (
"errors"
"net/http"
"sort"
"strings"
"time"
"github.com/kirsle/blog/jsondb"
"github.com/kirsle/golog"
)
// DB is a reference to the parent app's JsonDB object.
var DB *jsondb.DB
var log *golog.Logger
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"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"email"`
SMS string `json:"sms"`
LastSeen time.Time `json:"lastSeen"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
// NewContact initializes a new contact entry.
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++
}
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
}
}
return nil, errors.New("not found")
}
// 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")
}
// 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")
}
// Name returns a friendly name for the contact.
func (c *Contact) Name() string {
var parts []string
if c.FirstName != "" {
parts = append(parts, c.FirstName)
}
if c.LastName != "" {
parts = append(parts, c.LastName)
}
if len(parts) == 0 {
if c.Email != "" {
parts = append(parts, c.Email)
} else if c.SMS != "" {
parts = append(parts, c.SMS)
}
}
return strings.Join(parts, " ")
}
// ParseForm accepts form data for a contact.
func (c *Contact) ParseForm(r *http.Request) {
c.FirstName = r.FormValue("first_name")
c.LastName = r.FormValue("last_name")
c.Email = strings.ToLower(r.FormValue("email"))
c.SMS = r.FormValue("sms")
}
// Validate the contact form.
func (c *Contact) Validate() error {
if c.Email == "" && c.SMS == "" {
return errors.New("email or sms number required")
}
if c.FirstName == "" && c.LastName == "" {
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 {
return errors.New("email address already exists")
}
}
if c.SMS != "" {
if _, err := addr.GetSMS(c.SMS); err == nil {
return errors.New("sms number already exists")
}
}
return nil
}

View File

@ -0,0 +1,10 @@
package contacts
// ByName sorts contacts by name.
type ByName []*Contact
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 {
return a[i].Name() < a[j].Name()
}

View File

@ -39,21 +39,6 @@ type Event struct {
Updated time.Time `json:"updated"`
}
// RSVP tracks invitations and confirmations to events.
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"`
Notified bool `json:"notified"`
Name string `json:"name,omitempty"`
Status string `json:"status,omitempty"` // invited, going, maybe, not going
Email string `json:"email,omitempty"`
SMS string `json:"sms,omitempty"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
// New creates a blank event with sensible defaults.
func New() *Event {
return &Event{

70
models/events/invites.go Normal file
View File

@ -0,0 +1,70 @@
package events
import (
"errors"
"fmt"
"time"
"github.com/kirsle/blog/models/contacts"
)
// RSVP status constants.
const (
StatusInvited = "invited"
StatusGoing = "going"
StatusMaybe = "maybe"
StatusNotGoing = "not going"
)
// RSVP tracks invitations and confirmations to events.
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"`
}
// InviteContactID enters an invitation for a contact ID.
func (ev *Event) InviteContactID(id int) error {
// Make sure the ID isn't already in the list.
for _, rsvp := range ev.RSVP {
if rsvp.ContactID != 0 && rsvp.ContactID == id {
return errors.New("already invited")
}
}
ev.RSVP = append(ev.RSVP, RSVP{
ContactID: id,
Status: StatusInvited,
Created: time.Now().UTC(),
Updated: time.Now().UTC(),
})
return ev.Save()
}
// 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)
}
return result, nil
}

View File

@ -6,5 +6,5 @@ type ByDate []*Event
func (a ByDate) Len() int { return len(a) }
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[i].StartTime) || a[i].ID < a[j].ID
return a[i].StartTime.Before(a[j].StartTime) || a[i].ID < a[j].ID
}

View File

@ -1,108 +1,179 @@
{{ define "title" }}Invite People to {{ .Data.event.Title }}{{ end }}
{{ define "content" }}
<form action="/e/admin/edit" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
{{ $e := .Data.event }}
<h1>Invite <em>{{ $e.Title }}</em></h1>
{{ $e := .Data.event }}
{{ $cl := .Data.contacts }}
<div class="card mb-4">
<div class="card-header">Contact List</div>
<div class="card-body">
<div class="row" style="max-height: 500px; overflow: auto">
<div class="col-6">
<h4>Invited</h4>
<h1>Invite <em>{{ $e.Title }}</em></h1>
<ul class="list-unstyled">
<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">
<h4>Available</h4>
<div class="card mb-4">
<div class="card-header">Contact List</div>
<div class="card-body">
<div class="row" style="max-height: 500px; overflow: auto">
<div class="col-6">
<h4>Invited</h4>
<ul class="list-unstyled">
<li>
<label class="d-block alert alert-info">
<input type="checkbox" name="invite" value="1">
<strong>John Doe</strong><br>
<span class="text-muted">name@example.com</span>
</label>
</li>
<li>
<label class="d-block alert alert-info">
<input type="checkbox" name="invite" value="1">
<strong>John Doe</strong><br>
<span class="text-muted">name@example.com</span>
</label>
</li>
</ul>
<ul class="list-unstyled">
{{ range .Data.invited }}
<li>
<div class="alert alert-info">
{{ if .Contact }}
<strong>{{ .Contact.Name }}</strong>
{{ else }}
<strong>{{ .Name }}</strong>
{{ end }}
<ul class="list-inline">
{{ if .Contact }}
{{ if .Contact.Email }}
<li class="list-inline-item">
<a href="mailto:{{ .Contact.Email }}">{{ .Contact.Email }}</a>
</li>
{{ end }}
{{ if .Contact.SMS }}
<li class="list-inline-item">
<a href="tel:{{ .Contact.SMS }}">{{ .Contact.SMS }}</a>
</li>
{{ end }}
{{ else }}
{{ if .Email }}
<li class="list-inline-item">
<a href="mailto:{{ .Email }}">{{ .Email }}</a>
</li>
{{ end }}
{{ if .SMS }}
<li class="list-inline-item">
<a href="tel:{{ .SMS }}">{{ .SMS }}</a>
</li>
{{ end }}
{{ end }}
</ul>
</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">
<h4>Available</h4>
<button type="submit"
name="action" value="send-invite"
class="btn btn-primary">Send Invites</button>
</div>
<form action="/e/admin/invite/{{ $e.ID }}" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
<ul class="list-unstyled">
{{ range $cl.Contacts }}
{{ if not (index $.Data.invitedMap .ID) }}
<li>
<label class="d-block alert alert-info">
<input type="checkbox" name="invite" value="{{ .ID }}">
<strong>{{ .Name }}</strong>
<ul class="list-inline">
{{ if .Email }}
<li class="list-inline-item">
<a href="mailto:{{ .Email }}">{{ .Email }}</a>
</li>
{{ end }}
{{ if .SMS }}
<li class="list-inline-item">
<a href="tel:{{ .SMS }}">{{ .SMS }}</a>
</li>
{{ end }}
</ul>
</label>
</li>
{{ end }}
{{ end }}
<li>
<label class="d-block alert alert-info">
<input type="checkbox" name="invite" value="1">
<strong>John Doe</strong><br>
<span class="text-muted">name@example.com</span>
</label>
</li>
<li>
<label class="d-block alert alert-info">
<input type="checkbox" name="invite" value="1">
<strong>John Doe</strong><br>
<span class="text-muted">name@example.com</span>
</label>
</li>
</ul>
<button type="submit"
name="action" value="send-invite"
class="btn btn-primary">Send Invites</button>
<a href="/admin/contacts"
class="btn btn-secondary">Manage Contacts</a>
</form>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Invite New People</div>
<div class="card-body">
<div class="form-row">
<div class="form-group col-md-6">
<label for="first_name">First name:</label>
<input type="text"
name="first_name"
id="first_name"
class="form-control"
placeholder="First name">
</div>
<div class="form-group col-md-6">
<label for="last_name">Last name:</label>
<input type="text"
name="last_name"
id="last_name"
class="form-control"
placeholder="Last name">
</div>
<div class="card">
<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 }}">
<div class="form-row">
<div class="form-group col-md-6">
<label for="first_name">First name:</label>
<input type="text"
name="first_name"
id="first_name"
class="form-control"
placeholder="First name">
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="email">E-mail:</label>
<input type="email"
name="email"
id="email"
class="form-control"
placeholder="name@example.com">
</div>
<div class="form-group col-md-6">
<label for="last_name">SMS Number:</label>
<input type="text"
name="sms"
id="sms"
class="form-control"
placeholder="800-555-1234">
</div>
</div>
<div class="form-row">
<button type="button"
class="btn btn-primary">Send Invite</button>
<div class="form-group col-md-6">
<label for="last_name">Last name:</label>
<input type="text"
name="last_name"
id="last_name"
class="form-control"
placeholder="Last name">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="email">E-mail:</label>
<input type="email"
name="email"
id="email"
class="form-control"
placeholder="name@example.com">
</div>
<div class="form-group col-md-6">
<label for="last_name">SMS Number:</label>
<input type="text"
name="sms"
id="sms"
class="form-control"
placeholder="800-555-1234">
</div>
</div>
<div class="form-row">
<button type="submit"
name="action"
value="new-contact"
class="btn btn-primary">Send Invite</button>
</div>
</form>
</div>
</div>
<h2>Invited</h2>
<h2>Invited</h2>
To Do
</form>
To Do
{{ end }}

View File

@ -38,7 +38,7 @@
<div class="alert alert-danger">
<a href="/e/admin/edit?id={{ .Data.event.ID }}"
class="btn btn-primary">edit event</a>
<a href="/e/admin/invite?id={{ .Data.event.ID }}"
<a href="/e/admin/invite/{{ .Data.event.ID }}"
class="btn btn-success">invite people</a>
</div>
{{ end }}