From c3ef51bc753f48607032e77d0b344453c7142141 Mon Sep 17 00:00:00 2001 From: "Jakob L. Kreuze" Date: Tue, 12 Apr 2022 20:05:58 -0400 Subject: [scheme] Initial RSVP implementation --- dynamic/api.scm | 89 ++++++++++++++++++++++++++------- dynamic/schema-comments.sql | 12 +++++ dynamic/schema-rsvp.sql | 33 +++++++++++++ haunt/pages/rsvp.sxml | 37 ++++++++++++++ haunt/static/css/style.css | 16 ++++++ haunt/static/js/rsvp.js | 118 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 286 insertions(+), 19 deletions(-) create mode 100644 dynamic/schema-comments.sql create mode 100644 dynamic/schema-rsvp.sql create mode 100644 haunt/pages/rsvp.sxml create mode 100644 haunt/static/js/rsvp.js diff --git a/dynamic/api.scm b/dynamic/api.scm index 463ceab..1bd3540 100644 --- a/dynamic/api.scm +++ b/dynamic/api.scm @@ -1,3 +1,5 @@ +(add-to-load-path (dirname (current-filename))) + (use-modules (base64) (captcha) (json) @@ -5,6 +7,7 @@ (srfi srfi-11) (srfi srfi-13) (srfi srfi-26) + (squee) (rnrs bytevectors) (ice-9 match) (web server) @@ -12,24 +15,11 @@ (web response) (web uri)) -;; Data model for comments: -;; CREATE TABLE comments( -;; id SERIAL PRIMARY KEY, -;; approved TIMESTAMP, -;; submitted TIMESTAMP NOT NULL, -;; slug VARCHAR(100) NOT NULL, -;; name VARCHAR(50) NOT NULL, -;; email VARCHAR(100), -;; url VARCHAR(100), -;; comment VARCHAR(1024) NOT NULL -;; ); - -;; Use `now' for `submitted'. - ;; Globals. (define challenges (make-hash-table)) -;; (define conn (connect-to-postgres-paramstring "dbname=jakob-comments")) +;; (define conn (connect-to-postgres-paramstring "dbname=jakob_comments")) +(define conn (connect-to-postgres-paramstring "dbname=jakob_rsvp")) ;; Util. @@ -53,7 +43,7 @@ (define (decode-form bv) "Convert BV querystring or form data to an alist" - (define string (utf8->string bv)) + (define string (if (string? bv) bv (utf8->string bv))) (define pairs (map (cut string-split <> #\=) ;; semi-colon and amp can be used as pair separator (append-map (cut string-split <> #\;) @@ -94,13 +84,74 @@ (x-captcha-id . ,uuid)) (base64-encode image)))) +(define (valid-invite-code invitation) + (and (= (string-length)))) + +(define (post-event-rsvp request body) + (let* ((params (json-string->scm (utf8->string body))) + (invitation-code (assoc-ref params "id")) + (name (assoc-ref params "name")) + (email (assoc-ref params "email")) + (guests (assoc-ref params "guests"))) + (cond ((not (and invitation-code name email guests)) + (values (build-response #:code 400 #:headers '((Access-Control-Allow-Origin . "*"))) + (scm->json-string + `((success . #f) + (error . "Invalid form data"))))) + ((> 1 (length (exec-query conn "SELECT event_id FROM invitations WHERE vanity = $1" (list invitation-code)))) + (values (build-response #:code 400 #:headers '((Access-Control-Allow-Origin . "*"))) + (scm->json-string + `((success . #f) + (error . "Invalid invitation code"))))) + (else + (let* ((event-id (caar (exec-query conn "SELECT event_id FROM invitations WHERE vanity = $1" (list invitation-code))))) + (exec-query conn "INSERT INTO rsvps (event_id, fullname, email, guests) VALUES ($1, $2, $3, $4)" (list event-id name email guests)) + (values '((content-type . (application/json)) + (Access-Control-Allow-Origin . "*")) + (scm->json-string + `((success . #t))))))))) + +(define (get-event-info request body) + (define (format-rsvp rsvp) + (match rsvp + ((name email guests) + `((name . ,name) + (email . ,email) + (guests . ,guests))))) + (let* ((params (decode-form (uri-query (request-uri request)))) + (invitation-code (car (assoc-ref params "i"))) + (invitation (exec-query conn "SELECT event_id, capabilities FROM invitations WHERE vanity = $1" (list invitation-code)))) + (if (> 1 (length invitation)) + (values (build-response #:code 400 #:headers '((Access-Control-Allow-Origin . "*"))) + (scm->json-string + `((success . #f) + (error . "Invalid invitation code")))) + (let* ((capabilities (cadar invitation)) + (event (exec-query conn "SELECT * FROM events WHERE id = $1" (list (caar invitation)))) + (rsvps (exec-query conn "SELECT fullname, email, guests FROM rsvps WHERE event_id = $1" (list (caar invitation))))) + (match (car event) + ((i_ title description date location) + (values '((content-type . (application/json)) + (Access-Control-Allow-Origin . "*")) + (scm->json-string + `((title . ,title) + (description . ,description) + (image . #f) + (date . ,date) + (location . ,location) + ,@(if (= 1 (logand (string->number capabilities) 1)) + `((rsvps . ,(list->vector (map format-rsvp rsvps)))) + '())))))))))) + (define (handle-api-request request body endpoint) (display (cons (request-method request) endpoint)) (newline) ((match (cons (request-method request) endpoint) - ('(GET "challenge") make-challenge) - ('(GET "comments") get-comments) - ('(POST "comment") put-comment) + ;; ('(GET "challenge") make-challenge) + ;; ('(GET "comments") get-comments) + ;; ('(POST "comment") put-comment) + ('(GET "rsvp" "event-info") get-event-info) + ('(POST "rsvp") post-event-rsvp) (_ (lambda (. args) (not-found request)))) request body)) diff --git a/dynamic/schema-comments.sql b/dynamic/schema-comments.sql new file mode 100644 index 0000000..1c690f3 --- /dev/null +++ b/dynamic/schema-comments.sql @@ -0,0 +1,12 @@ +CREATE TABLE comments( + id SERIAL PRIMARY KEY, + approved TIMESTAMP, + submitted TIMESTAMP NOT NULL, + slug VARCHAR(100) NOT NULL, + name VARCHAR(50) NOT NULL, + email VARCHAR(100), + url VARCHAR(100), + comment VARCHAR(1024) NOT NULL +); + +-- Use `now' for `submitted'. diff --git a/dynamic/schema-rsvp.sql b/dynamic/schema-rsvp.sql new file mode 100644 index 0000000..42c8c41 --- /dev/null +++ b/dynamic/schema-rsvp.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS events ( + id SERIAL, + title varchar(128) NOT NULL, + description varchar(16384) NOT NULL, + datetime timestamp with time zone NOT NULL, + location varchar(128) NOT NULL, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS invitations ( + id SERIAL, + vanity char(12) NOT NULL, + comments varchar(1024) NOT NULL, + created_on timestamp with time zone default current_timestamp, + capabilities bigint NOT NULL, + event_id integer NOT NULL, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS rsvps ( + id SERIAL, + vanity char(12) NOT NULL, + event_id bigint NOT NULL, + fullname varchar(128) NOT NULL, + email varchar(256) NOT NULL, + guests varchar(1024) NOT NULL, + attending varchar(32) NOT NULL, + PRIMARY KEY (id) +); + +-- `rsvps` contains the bare minimum. If we later decide we need additional +-- fields, we'll have an additional table mapping events.id to attribute names +-- and rsvps.id to attribute values. diff --git a/haunt/pages/rsvp.sxml b/haunt/pages/rsvp.sxml new file mode 100644 index 0000000..617350f --- /dev/null +++ b/haunt/pages/rsvp.sxml @@ -0,0 +1,37 @@ +(use-modules (jakob theme) + (jakob utils sxml)) + +(theme + #:title "You've Been Invited to an Event!" + #:content + `((div (@ (id "rsvp")) + (div (@ (id "loading")) + ,(image "hourglass.gif") + (p "Loading...")) + (div (@ (id "event-info"))) + (form (@ (id "rsvp-input") (hidden #t)) + (fieldset + (legend "Your Info") + (label (@ (for "name")) "Name:") + (input (@ (type "text") (id "name") (name "name") (required #t) (size 24))) + (label (@ (for "email")) "Email:") + (input (@ (type "text") (id "email") (name "email") (required #t) (size 24)))) + (fieldset + (legend "RSVP Status") + (input (@ (type "radio") (id "attending") (name "rsvp"))) + (label (@ (for "attending")) "Attending") + (input (@ (type "radio") (id "tentative") (name "rsvp"))) + (label (@ (for "tentative")) "Tentative") + (input (@ (type "radio") (id "not-attending") (name "rsvp"))) + (label (@ (for "not-attending")) "Not Attending")) + (fieldset + (legend "Guests") + (div (@ (id "guest-view"))) + (input (@ (type "button") (id "add-entry") (value "+"))) + (input (@ (type "button") (id "remove-entry") (value "-")))) + (fieldset + (legend "All Set?") + (input (@ (type "button") (id "submit-form") (value "Submit"))))) + (div (@ (id "rsvp-global")))) + + ,(script "rsvp.js"))) diff --git a/haunt/static/css/style.css b/haunt/static/css/style.css index f6a1c08..58db3a9 100644 --- a/haunt/static/css/style.css +++ b/haunt/static/css/style.css @@ -408,3 +408,19 @@ button { background-position: 1.5em 50%; padding: 0.25em 1em 0.25em 7em; } + +#loading { + border: 1px solid; + width: fit-content; + padding: 5px; + margin: auto; +} + +fieldset { + padding: 12px; + margin: 15px; +} + +#rsvp-input input { + margin: 0px 16px; +} diff --git a/haunt/static/js/rsvp.js b/haunt/static/js/rsvp.js new file mode 100644 index 0000000..9ba98ac --- /dev/null +++ b/haunt/static/js/rsvp.js @@ -0,0 +1,118 @@ +/* + * rsvp.js -- Simple web-based RSVP tool. + * Copyright © 2022 Jakob L. Kreuze + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * . + */ + +const urlParams = new URLSearchParams(window.location.search); +if (!urlParams.get("i")) { + document.getElementById("loading").remove(); + let elem = document.createElement("p"); + elem.innerHTML = "No invitation code provided."; + document.getElementById("rsvp").appendChild(elem); +} else { + let xhr = new XMLHttpRequest(); + xhr.open("GET", `http://localhost:8081/api/rsvp/event-info?i=${urlParams.get("i")}`); + xhr.send(); + xhr.onload = function(data) { + document.getElementById("loading").remove(); + if (xhr.status != 200) { + let elem = document.createElement("p"); + elem.innerHTML = "Invalid invitation code."; + document.getElementById("rsvp").appendChild(elem); + } else { + document.getElementById("rsvp-input").hidden = false; + + let response = JSON.parse(xhr.response); + + let elem = document.createElement("h1"); + elem.innerHTML = response.title; + document.getElementById("event-info").appendChild(elem); + + elem = document.createElement("p"); + elem.innerHTML = `Where: ${response.location}`; + document.getElementById("event-info").appendChild(elem); + + elem = document.createElement("p"); + elem.innerHTML = `When: ${response.date}`; + document.getElementById("event-info").appendChild(elem); + + elem = document.createElement("p"); + elem.innerHTML = response.description; + document.getElementById("event-info").appendChild(elem); + + if (response.hasOwnProperty("rsvps")) { + elem = document.createElement("h2"); + elem.innerHTML = "Current RSVPs"; + document.getElementById("rsvp-global").appendChild(elem); + + elem = document.createElement("table"); + document.getElementById("rsvp-global").appendChild(elem); + + for (let rsvp of response.rsvps) { + console.log(rsvp.name); + let container = document.createElement("tr"); + let field = document.createElement("td"); + field.textContent = rsvp.name; + container.append(field); + field = document.createElement("td"); + field.textContent = rsvp.email; + container.append(field); + field = document.createElement("td"); + field.textContent = rsvp.guests.replace(',', ', '); + container.append(field); + field = document.createElement("td"); + field.textContent = rsvp.attending; + container.append(field); + elem.appendChild(container); + } + } + + console.log(response); + } + } + xhr.onerror = function(error) { + throw new Error(`Request failed: ${error}`); + } +} + +const guestView = document.getElementById("guest-view"); +const addEntry = document.getElementById("add-entry"); +const removeEntry = document.getElementById("remove-entry"); +const submitButton = document.getElementById("submit-form"); +const form = document.getElementById("rsvp-input"); + +addEntry.addEventListener("click", () => { + let i = guestView.childNodes.length; + let elem = document.createElement("input"); + elem.type = "text"; + elem.id = `guest-${i}`; + guestView.appendChild(elem); +}); + +removeEntry.addEventListener("click", () => { + if (guestView.lastChild) { + guestView.lastChild.remove(); + } +}); + +function submitForm() { + let data = new FormData(form); + console.log(data); +} + +submitButton.addEventListener("click", submitForm); +submitButton.addEventListener("submit", submitForm); -- cgit v1.3