dodo
diff --git a/apps/dodo/main.go b/apps/dodo/main.go
new file mode 100644
index 0000000..3445b59
--- /dev/null
+++ b/apps/dodo/main.go
@@ -0,0 +1,202 @@
+package main
+
+import (
+	"database/sql"
+	_ "embed"
+	"flag"
+	"fmt"
+	"log"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+
+	_ "github.com/glebarez/go-sqlite"
+)
+
+var port = flag.Int("port", 8080, "Port to listen on")
+var dbPath = flag.String("db-path", "entries.db", "Path to the sqlite file")
+
+//go:embed index.html
+var indexHtml []byte
+
+//go:embed ok.html
+var okHtml []byte
+
+type entry struct {
+	Email            string
+	InstallationType string
+	NumMembers       int
+	Apps             []string
+	PayPerMonth      float64
+	PrepayFullYear   bool
+	Thoughts         string
+}
+
+func getFormValues(f url.Values, name string) ([]string, error) {
+	return f[name], nil
+}
+
+func getFormValue(f url.Values, name string) (string, error) {
+	if ret, ok := f[name]; ok {
+		switch len(ret) {
+		case 0:
+			return "", fmt.Errorf("%s is required", name)
+		case 1:
+			return ret[0], nil
+		default:
+			return "", fmt.Errorf("%s too many values", name)
+		}
+	}
+	return "", fmt.Errorf("%s is required", name)
+}
+
+func getFormValueInt(f url.Values, name string) (int, error) {
+	v, err := getFormValue(f, name)
+	if err != nil {
+		return 0, err
+	}
+	return strconv.Atoi(v)
+}
+
+func getFormValueBool(f url.Values, name string) (bool, error) {
+	v, err := getFormValue(f, name)
+	if err != nil {
+		return false, err
+	}
+	return strconv.ParseBool(v)
+}
+
+func getFormValueFloat64(f url.Values, name string) (float64, error) {
+	v, err := getFormValue(f, name)
+	if err != nil {
+		return 0, err
+	}
+	return strconv.ParseFloat(v, 64)
+}
+
+func NewEntry(f url.Values) (*entry, error) {
+	var e entry
+	var err error = nil
+	e.Email, err = getFormValue(f, "email")
+	if err != nil {
+		return nil, err
+	}
+	e.InstallationType, err = getFormValue(f, "installation_type")
+	if err != nil {
+		return nil, err
+	}
+	e.NumMembers, err = getFormValueInt(f, "num_members")
+	if err != nil {
+		return nil, err
+	}
+	e.Apps, err = getFormValues(f, "apps")
+	if err != nil {
+		return nil, err
+	}
+	e.PayPerMonth, err = getFormValueFloat64(f, "pay_per_month")
+	if err != nil {
+		return nil, err
+	}
+	e.PrepayFullYear, err = getFormValueBool(f, "pay_full_year")
+	if err != nil {
+		return nil, err
+	}
+	e.Thoughts, err = getFormValue(f, "thoughts")
+	if err != nil {
+		return nil, err
+	}
+	return &e, nil
+}
+
+type AddToWaitlistFn func(e *entry) error
+
+type Server struct {
+	port          int
+	indexHtml     []byte
+	okHtml        []byte
+	addToWaitlist func(e *entry) error
+}
+
+func NewServer(port int, indexHtml, okHtml []byte, addToWaitlist AddToWaitlistFn) *Server {
+	return &Server{
+		port,
+		indexHtml,
+		okHtml,
+		addToWaitlist,
+	}
+}
+
+func (s *Server) Start() {
+	http.HandleFunc("/waitlist", s.waitlist)
+	http.HandleFunc("/", s.index)
+	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
+}
+
+func (s *Server) index(w http.ResponseWriter, r *http.Request) {
+	if _, err := w.Write(s.indexHtml); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) waitlist(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "Invalid request", http.StatusBadRequest)
+		return
+	}
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "Invalid request", http.StatusBadRequest)
+		return
+	}
+	e, err := NewEntry(r.PostForm)
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Invalid request: %s", err), http.StatusBadRequest)
+		return
+	}
+	if err := s.addToWaitlist(e); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Write([]byte(s.okHtml))
+}
+
+func NewAddToWaitlist(db *sql.DB) AddToWaitlistFn {
+	return func(e *entry) error {
+		tx, err := db.Begin()
+		if err != nil {
+			return err
+		}
+		stm, err := tx.Prepare(`
+INSERT INTO waitlist (
+  email,
+  installation_type,
+  num_members,
+  apps,
+  pay_per_month,
+  prepay_full_year,
+  thoughts
+) VALUES (
+  ?, ?, ?, ?, ?, ?, ?
+);`)
+		if err != nil {
+			return err
+		}
+		defer stm.Close()
+		if _, err := stm.Exec(e.Email, e.InstallationType, e.NumMembers, strings.Join(e.Apps, ","), e.PayPerMonth, e.PrepayFullYear, e.Thoughts); err != nil {
+			return err
+		}
+		return tx.Commit()
+	}
+}
+
+func main() {
+	flag.Parse()
+	db, err := sql.Open("sqlite", *dbPath)
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer db.Close()
+	s := NewServer(*port, indexHtml, okHtml, NewAddToWaitlist(db))
+	s.Start()
+}