Contact creation and invite UI
This commit is contained in:
parent
765e80b64d
commit
345878fabe
2
blog.go
2
blog.go
|
@ -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?
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
164
models/contacts/contacts.go
Normal 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
|
||||
}
|
10
models/contacts/sorting.go
Normal file
10
models/contacts/sorting.go
Normal 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()
|
||||
}
|
|
@ -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
70
models/events/invites.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
{{ 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 }}
|
||||
{{ $cl := .Data.contacts }}
|
||||
|
||||
<h1>Invite <em>{{ $e.Title }}</em></h1>
|
||||
|
||||
<div class="card mb-4">
|
||||
|
@ -14,6 +14,42 @@
|
|||
<h4>Invited</h4>
|
||||
|
||||
<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>
|
||||
|
@ -27,7 +63,32 @@
|
|||
<div class="col-6">
|
||||
<h4>Available</h4>
|
||||
|
||||
<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">
|
||||
|
@ -47,6 +108,10 @@
|
|||
<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>
|
||||
|
@ -55,6 +120,9 @@
|
|||
<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>
|
||||
|
@ -94,15 +162,18 @@
|
|||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<button type="button"
|
||||
<button type="submit"
|
||||
name="action"
|
||||
value="new-contact"
|
||||
class="btn btn-primary">Send Invite</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Invited</h2>
|
||||
|
||||
To Do
|
||||
</form>
|
||||
|
||||
{{ end }}
|
||||
|
|
|
@ -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 }}
|
||||
|
|
Loading…
Reference in New Issue
Block a user