diff --git a/config/database/21-member-types.sql b/config/database/21-member-types.sql deleted file mode 100644 index ed7ae90..0000000 --- a/config/database/21-member-types.sql +++ /dev/null @@ -1,6 +0,0 @@ -SET ROLE kansa; - -CREATE TYPE MembershipStatus AS ENUM ( - 'NonMember', 'Exhibitor', 'Helper', 'Supporter', 'KidInTow', - 'Child', 'Youth', 'FirstWorldcon', 'Adult' -); diff --git a/config/database/README.md b/config/database/README.md index 72eb51c..e36c739 100644 --- a/config/database/README.md +++ b/config/database/README.md @@ -9,11 +9,8 @@ If you end up making changes later to values or tables that are defined in your ## Required Config Some of the configuration is required, and run at specific points during the initialisation: -### `21-member-types.sql` -Defines the types of memberships that are supported. The order of values matters for upgrades, so use something like this if adding member types: -```sql -ALTER TYPE kansa.MembershipStatus ADD VALUE 'KidInTow' BEFORE 'Child'; -``` +### `member-types.sql` +Defines the types of memberships that are supported, and their attributes. ### `31-hugo-categories.sql` Defines the Hugo Awards categories. The order of values defines their listing order, so use something like this if adding categories: diff --git a/config/database/dev-people.sql b/config/database/dev-people.sql index 07ebe1e..b92d045 100644 --- a/config/database/dev-people.sql +++ b/config/database/dev-people.sql @@ -6,20 +6,20 @@ INSERT INTO admin.Admins (email, member_admin, member_list, siteselection, hugo_ SET ROLE kansa; -INSERT INTO People (legal_name, email, membership, member_number, hugo_nominator, hugo_voter) - VALUES ('Admin', 'admin@example.com', 'NonMember', NULL, false, false), - ('Member Admin', 'member-admin@example.com', 'NonMember', NULL, false, false), - ('Site Selection', 'site-select@example.com', 'NonMember', NULL, false, false), - ('Hugo Admin', 'hugo-admin@example.com', 'NonMember', NULL, false, false), - ('First Member', 'member@example.com', 'FirstWorldcon', 2, true, true), - ('Fan Parent', 'family@example.com', 'Adult', 3, true, true), - ('Fan Child', 'family@example.com', 'Child', 4, false, false), - ('Fan Youth', 'family@example.com', 'Youth', 5, true, true), - ('Fan Supporter', 'supporter@example.com', 'Supporter', 6, true, true), - ('Dupe Supporter', 'supporter@example.com', 'Supporter', 7, false, false), - ('Fan Trader', 'trader@example.com', 'Exhibitor', 8, false, false), - ('Fan Helper', 'helper@example.com', 'Helper', 9, false, false), - ('Fan Nominator', 'nominator@example.com', 'NonMember', NULL, true, false); +INSERT INTO People (legal_name, email, membership, member_number) + VALUES ('Admin', 'admin@example.com', 'NonMember', NULL), + ('Member Admin', 'member-admin@example.com', 'NonMember', NULL), + ('Site Selection', 'site-select@example.com', 'NonMember', NULL), + ('Hugo Admin', 'hugo-admin@example.com', 'NonMember', NULL), + ('First Member', 'member@example.com', 'FirstWorldcon', 2), + ('Fan Parent', 'family@example.com', 'Adult', 3), + ('Fan Child', 'family@example.com', 'Child', 4), + ('Fan Youth', 'family@example.com', 'Youth', 5), + ('Fan Supporter', 'supporter@example.com', 'Supporter', 6), + ('Dupe Supporter', 'supporter@example.com', 'Supporter', 7), + ('Fan Trader', 'trader@example.com', 'Exhibitor', 8), + ('Fan Helper', 'helper@example.com', 'Helper', 9), + ('Fan Nominator', 'nominator@example.com', 'HugoNominator', NULL); INSERT INTO Keys VALUES ('admin@example.com', 'key'), diff --git a/config/database/fake-people.sql b/config/database/fake-people.sql index f2fb4ee..db1b55f 100644 --- a/config/database/fake-people.sql +++ b/config/database/fake-people.sql @@ -19,6 +19,8 @@ SET row_security = off; -- Data for Name: people; Type: TABLE DATA; Schema: kansa; Owner: kansa -- +ALTER TABLE kansa.people ADD COLUMN hugo_nominator bool; +ALTER TABLE kansa.people ADD COLUMN hugo_voter bool; COPY kansa.people (id, last_modified, membership, member_number, legal_name, public_first_name, public_last_name, email, city, state, country, badge_name, badge_subtitle, hugo_nominator, hugo_voter, paper_pubs) FROM stdin; 15 2018-07-29 18:14:07.698028+00 FirstWorldcon 43 Factual Blob Fake (Factual) Blob fblog@fblobsblog.com Fact City Data Central Blobitania \N \N \N \N \N 14 2018-07-29 18:14:07.701524+00 Supporter 42 Samuel Vimes \N \N s.vimes@gmial.ocm Ankh-Morpork \N The Disc \N \N \N \N {"name": "Samuel Vimes", "address": "11, Broadstreet\\r\\nAnkh-Morpork", "country": "Ankh-Morpork"} @@ -669,6 +671,8 @@ COPY kansa.people (id, last_modified, membership, member_number, legal_name, pub 660 2018-07-29 18:14:40.950855+00 KidInTow 688 Falkrunn Ungart Falkrunn Ungart intricatemasonryyay@ymail.com Waren Kein Denmark \N \N \N \N \N \. +ALTER TABLE kansa.people DROP COLUMN hugo_nominator; +ALTER TABLE kansa.people DROP COLUMN hugo_voter; -- -- Data for Name: log; Type: TABLE DATA; Schema: kansa; Owner: kansa diff --git a/config/database/membership-types.sql b/config/database/membership-types.sql new file mode 100644 index 0000000..04788e0 --- /dev/null +++ b/config/database/membership-types.sql @@ -0,0 +1,14 @@ +SET ROLE kansa; + +INSERT INTO membership_types ( + membership, allow_lookup, badge, daypass_available, hugo_nominator, member, wsfs_member) VALUES +('NonMember', true, false, false, false, false, false), +('HugoNominator', true, false, false, true, false, false), +('Exhibitor', true, true, false, false, true, false), +('Helper', true, true, false, false, true, false), +('Supporter', true, false, false, true, true, true), +('KidInTow', false, true, false, false, true, false), +('Child', false, true, true, false, true, false), +('Youth', true, true, true, true, true, true), +('FirstWorldcon', true, true, false, true, true, true), +('Adult', true, true, true, true, true, true); diff --git a/config/docker-compose.base.yaml b/config/docker-compose.base.yaml index 3a1e25c..746d25a 100644 --- a/config/docker-compose.base.yaml +++ b/config/docker-compose.base.yaml @@ -95,12 +95,12 @@ services: - pgdata:/pgdata - ../postgres/init/10-admin-init.sql:/docker-entrypoint-initdb.d/10-admin-init.sql:ro - ../postgres/init/20-kansa-init.sql:/docker-entrypoint-initdb.d/20-kansa-init.sql:ro - - ../config/database/21-member-types.sql:/docker-entrypoint-initdb.d/21-member-types.sql:ro - ../postgres/init/22-kansa-tables.sql:/docker-entrypoint-initdb.d/22-kansa-tables.sql:ro - ../postgres/init/25-day-passes.sql:/docker-entrypoint-initdb.d/25-day-passes.sql:ro - ../postgres/init/25-payments.sql:/docker-entrypoint-initdb.d/25-payments.sql:ro - ../postgres/init/25-public-data.sql:/docker-entrypoint-initdb.d/25-public-data.sql:ro - ../postgres/init/28-siteselection.sql:/docker-entrypoint-initdb.d/28-siteselection.sql:ro + - ../config/database/membership-types.sql:/docker-entrypoint-initdb.d/29-membership-types.sql:ro - ../postgres/init/30-hugo-init.sql:/docker-entrypoint-initdb.d/30-hugo-init.sql:ro - ../config/database/31-hugo-categories.sql:/docker-entrypoint-initdb.d/31-hugo-categories.sql:ro - ../postgres/init/32-hugo-tables.sql:/docker-entrypoint-initdb.d/32-hugo-tables.sql:ro diff --git a/docs/index.md b/docs/index.md index 238dba9..0aaf998 100644 --- a/docs/index.md +++ b/docs/index.md @@ -88,7 +88,16 @@ The configuration used by this instance. { id: 'w75', name: 'Worldcon 75', - paid_paper_pubs: true + paid_paper_pubs: true, + membershipTypes: { + Adult: { + badge: true, + hugo_nominator: true, + member: true, + wsfs_member: true + }, + ... + } } ``` @@ -240,7 +249,6 @@ data, `member_admin` or `member_list` authority is required. { id, last_modified, member_number, membership, legal_name, email, public_first_name, public_last_name, city, state, country, - hugo_nominator, hugo_voter, paper_pubs: { name, address, country }, preferred_name, daypass, daypass_days } diff --git a/hugo/lib/admin.js b/hugo/lib/admin.js index f07dcfa..fe87641 100644 --- a/hugo/lib/admin.js +++ b/hugo/lib/admin.js @@ -100,18 +100,16 @@ class Admin { } _classifyWithObject(category, nomination, getInsertQuery) { - return this.db.tx(tx => tx.sequence((i, data) => { switch (i) { - case 0: - return tx.one(` - INSERT INTO Canon (category, nomination) - VALUES ($(category), $(nomination)::jsonb) - ON CONFLICT (category, nomination) - DO UPDATE SET category = EXCLUDED.category - RETURNING id - `, { category, nomination }); // DO UPDATE required for non-empty RETURNING id - case 1: - return tx.none(getInsertQuery(data.id)); - }})) + return this.db.tx(tx => + tx.one(` + INSERT INTO Canon (category, nomination) + VALUES ($(category), $(nomination)::jsonb) + ON CONFLICT (category, nomination) + DO UPDATE SET category = EXCLUDED.category + RETURNING id`, { category, nomination } // DO UPDATE required for non-empty RETURNING id + ) + .then(({ id }) => tx.none(getInsertQuery(id))) + ) } classify(req, res, next) { diff --git a/hugo/lib/nominate.js b/hugo/lib/nominate.js index 913438c..8585d72 100644 --- a/hugo/lib/nominate.js +++ b/hugo/lib/nominate.js @@ -11,13 +11,18 @@ function access(req) { const id = parseInt(req.params.id); if (isNaN(id) || id < 0) return Promise.reject(new InputError('Bad id number')); if (!req.session || !req.session.user || !req.session.user.email) return Promise.reject(new AuthError()); - return req.app.locals.db.oneOrNone('SELECT email, hugo_nominator, hugo_voter FROM kansa.People WHERE id = $1', id) + return req.app.locals.db.oneOrNone(` + SELECT p.email, m.hugo_nominator, m.wsfs_member + FROM kansa.people p + LEFT JOIN kansa.membership_types m USING (membership) + WHERE id = $1`, id + ) .then(data => { if (!data || !req.session.user.hugo_admin && req.session.user.email !== data.email) throw new AuthError(); return { id, nominator: !!data.hugo_nominator, - voter: !!data.hugo_voter + voter: !!data.wsfs_member }; }); } diff --git a/hugo/lib/vote.js b/hugo/lib/vote.js index 74c78cc..bd09b05 100644 --- a/hugo/lib/vote.js +++ b/hugo/lib/vote.js @@ -24,15 +24,16 @@ class Vote { if (isNaN(id) || id < 0) return Promise.reject(new InputError('Bad id number')); if (!req.session || !req.session.user || !req.session.user.email) return Promise.reject(new AuthError()); return this.db.oneOrNone(` - SELECT email, hugo_voter - FROM kansa.People - WHERE id = $1`, id + SELECT p.email, m.wsfs_member + FROM kansa.People p + LEFT JOIN kansa.membership_types m USING (membership) + WHERE id = $1`, id ) .then(data => { if (!data || !req.session.user.hugo_admin && req.session.user.email !== data.email) throw new AuthError(); return { id, - voter: !!data.hugo_voter + voter: !!data.wsfs_member }; }); } diff --git a/kansa/app.js b/kansa/app.js index 19048d7..3a1eeb2 100644 --- a/kansa/app.js +++ b/kansa/app.js @@ -46,11 +46,11 @@ const peopleStream = new PeopleStream(db); router.get('/public/people', cors({ origin: '*' }), publicData.getPublicPeople); router.get('/public/stats', cors({ origin: '*' }), publicData.getPublicStats); router.get('/public/daypass-stats', cors({ origin: '*' }), publicData.getDaypassStats); +router.get('/config', publicData.getConfig); router.post('/key', key.setKey); router.all('/login', user.login); -router.get('/config', (req, res) => res.json(config)); router.get('/barcode/:key/:id.:fmt', badge.getBarcode); router.get('/blank-badge', badge.getBadge); diff --git a/kansa/lib/badge.js b/kansa/lib/badge.js index c00c82a..76ec7d7 100644 --- a/kansa/lib/badge.js +++ b/kansa/lib/badge.js @@ -39,8 +39,12 @@ const splitNameInTwain = (name) => { function getBadge(req, res, next) { const id = parseInt(req.params.id || '0') req.app.locals.db.oneOrNone(` - SELECT member_number, membership, get_badge_name(p) AS name, get_badge_subtitle(p) AS subtitle - FROM people p WHERE id = $1 AND membership != 'Supporter'`, id + SELECT + p.member_number, membership, + get_badge_name(p) AS name, get_badge_subtitle(p) AS subtitle + FROM people p + LEFT JOIN membership_types m USING (membership) + WHERE id = $1 AND m.badge = true`, id ) .then(data => { const { member_number, membership, name, subtitle } = data || {} @@ -75,12 +79,15 @@ function getBarcode(req, res, next) { const format = req.params.fmt === 'pdf' ? 'pdf' : 'png' req.app.locals.db.one(` SELECT member_number, membership, - get_badge_name(p) AS name, get_badge_subtitle(p) AS subtitle, - d.status AS daypass, daypass_days(d) AS days - FROM people p + get_badge_name(p) AS name, get_badge_subtitle(p) AS subtitle, + d.status AS daypass, daypass_days(d) AS days + FROM people p JOIN keys k USING (email) - LEFT JOIN daypasses d ON (p.id = d.person_id) - WHERE p.id=$(id) ${key ? 'AND key=$(key)' : ''} AND membership != 'Supporter'`, { id, key } + LEFT JOIN membership_types m USING (membership) + LEFT JOIN daypasses d ON (p.id = d.person_id) + WHERE p.id=$(id) AND + (m.badge = true OR d.status IS NOT NULL) + ${key ? 'AND key=$(key)' : ''}`, { id, key } ) .then(data => { const { daypass, days, member_number, membership, name, subtitle } = data diff --git a/kansa/lib/mail.js b/kansa/lib/mail.js index 11adf15..f6de2d2 100644 --- a/kansa/lib/mail.js +++ b/kansa/lib/mail.js @@ -15,9 +15,6 @@ function mailTask(type, data, delay) { } const mailRecipient = (email, res) => { - const mt = [ 'NonMember', 'KidInTow', 'Exhibitor', 'Helper', 'Child', 'Supporter', 'Youth', 'FirstWorldcon', 'Adult' ] - // inlined as types/person.js has Supporter < Child - const mi = res.reduce((max, r) => Math.max(max, mt.indexOf(r.membership)), -1) let name = res[0].name switch (res.length) { case 0: return { email, delete: true } @@ -32,32 +29,29 @@ const mailRecipient = (email, res) => { name = names.join(', ') } const attending = res - .filter(r => { - switch (r.membership) { - case 'Supporter': return false - case 'NonMember': return !!r.daypass - default: return true - } - }) + .filter(r => r.badge || r.daypass) .map(({ id, name }) => ({ id, name })) const hugo_members = res - .filter(r => r.hugo_nominator || r.hugo_voter) + .filter(r => r.hugo_nominator || r.wsfs_member) .map(({ id, name }) => ({ id, name })) return { attending, email: res[0].email, hugo_members, key: res[0].key, - membership: mt[mi], + membership: res[0].membership, // FIXME: see https://github.com/maailma/kansa/issues/49 name } } mailRecipient.selector = ` - SELECT email, key, p.id, membership, preferred_name(p) as name, - hugo_nominator, hugo_voter, d.status AS daypass - FROM people p -LEFT JOIN keys USING (email) -LEFT JOIN daypasses d ON (p.id = d.person_id)` + SELECT + email, key, p.id, membership, preferred_name(p) as name, + m.badge, m.hugo_nominator, m.wsfs_member, + d.status AS daypass + FROM people p + LEFT JOIN keys USING (email) + LEFT JOIN membership_type m USING (membership) + LEFT JOIN daypasses d ON (p.id = d.person_id)` function rxUpdateTask(recipients) { return fetch('http://kyyhky/update-recipients', { diff --git a/kansa/lib/people.js b/kansa/lib/people.js index 71836e4..874c76f 100644 --- a/kansa/lib/people.js +++ b/kansa/lib/people.js @@ -23,8 +23,6 @@ function getPeopleQuery(req, res, next) { return '(legal_name ILIKE $(name) OR public_first_name ILIKE $(name) OR public_last_name ILIKE $(name))'; case 'member_number': case 'membership': - case 'hugo_nominator': - case 'hugo_voter': return `${fn} = $(${fn})`; default: return (Person.fields.indexOf(fn) !== -1) ? `${fn} ILIKE $(${fn})` : 'true'; @@ -37,9 +35,12 @@ function getPeopleQuery(req, res, next) { function getMemberEmails(req, res, next) { if (!req.session.user.member_admin) return res.status(401).json({ status: 'unauthorized' }); req.app.locals.db.any(` - SELECT lower(email) AS email, legal_name AS ln, public_first_name AS pfn, public_last_name AS pln - FROM People - WHERE email != '' AND membership != 'NonMember' + SELECT + lower(email) AS email, legal_name AS ln, + public_first_name AS pfn, public_last_name AS pln + FROM People p + LEFT JOIN membership_types m USING (membership) + WHERE email != '' AND m.member = true ORDER BY public_last_name, public_first_name, legal_name` ) .then(raw => { @@ -71,11 +72,13 @@ function getMemberEmails(req, res, next) { function getMemberPaperPubs(req, res, next) { if (!req.session.user.member_admin) return res.status(401).json({ status: 'unauthorized' }); req.app.locals.db.any(` - SELECT paper_pubs->>'name' AS name, - paper_pubs->>'address' AS address, - paper_pubs->>'country' AS country - FROM People - WHERE paper_pubs IS NOT NULL AND membership != 'NonMember'` + SELECT + paper_pubs->>'name' AS name, + paper_pubs->>'address' AS address, + paper_pubs->>'country' AS country + FROM People p + LEFT JOIN membership_types m USING (membership) + WHERE paper_pubs IS NOT NULL AND m.member = true` ) .then(data => { res.status(200).csv(data, true); @@ -177,29 +180,27 @@ function addPerson(req, db, person) { } const log = new LogEntry(req, 'Add new person'); let res; - return db.tx(tx => tx.sequence((i, data) => { switch (i) { - - case 0: - return tx.one(`INSERT INTO People ${person.sqlValues} RETURNING id, member_number`, person.data); - - case 1: - person.data.id = data.id - person.data.member_number = data.member_number - res = data; - log.subject = data.id - return tx.none(`INSERT INTO Log ${log.sqlValues}`, log) - - case 2: - if (passDays.length) { - person.data.membership = status + return db.tx(tx => + tx.one(` + INSERT INTO People ${person.sqlValues} + RETURNING id, member_number`, person.data + ) + .then(data => { + person.data.id = data.id + person.data.member_number = data.member_number + res = data; + log.subject = data.id + return tx.none(`INSERT INTO Log ${log.sqlValues}`, log) + }) + .then(() => { + if (passDays.length === 0) return null const trueDays = passDays.map(d => 'true').join(',') return tx.none(` INSERT INTO daypasses (person_id,status,${passDays.join(',')}) - VALUES ($(id),$(membership),${trueDays})`, person.data + VALUES ($(id),$(status),${trueDays})`, { id: res.id, status } ) - } - - }})) + }) + ) .then(() => res); } @@ -218,61 +219,89 @@ function authAddPerson(req, res, next) { .catch(next); } -function updatePerson(req, res, next) { - const data = Object.assign({}, req.body); - const isMemberAdmin = req.session.user.member_admin; - const fieldSrc = isMemberAdmin ? Person.fields : Person.userModFields; - const fields = fieldSrc.filter(fn => data.hasOwnProperty(fn)); - if (fields.length == 0) return res.status(400).json({ status: 'error', message: 'No valid parameters' }); +function getUpdateQuery(data, id, isAdmin) { + const values = Object.assign({}, data, { id }) + const fieldSrc = isAdmin ? Person.fields : Person.userModFields; + const fields = fieldSrc.filter(f => values.hasOwnProperty(f)); + if (fields.length == 0) throw new InputError('No valid parameters'); let ppCond = ''; - if (fields.indexOf('paper_pubs') >= 0) try { - data.paper_pubs = Person.cleanPaperPubs(data.paper_pubs); - if (config.paid_paper_pubs && !isMemberAdmin) { - if (!data.paper_pubs) { - const message = 'Removing paid paper publications is not allowed' - return res.status(400).json({ status: 'error', message }) - } + if (fields.indexOf('paper_pubs') >= 0) { + values.paper_pubs = Person.cleanPaperPubs(values.paper_pubs); + if (config.paid_paper_pubs && !isAdmin) { + if (!values.paper_pubs) throw new InputError('Removing paid paper publications is not allowed') ppCond = 'AND paper_pubs IS NOT NULL'; } - } catch (e) { - return res.status(400).json({ status: 'error', message: 'paper_pubs: ' + e.message }); } - const sqlFields = fields.map(fn => `${fn}=$(${fn})`).join(', '); - const log = new LogEntry(req, 'Update fields: ' + fields.join(', ')); - data.id = log.subject = parseInt(req.params.id); - const db = req.app.locals.db; - let email = data.email; - db.tx(tx => tx.batch([ - tx.one(` - WITH prev AS (SELECT email FROM People WHERE id=$(id)) - UPDATE People p - SET ${sqlFields} - WHERE id=$(id) ${ppCond} - RETURNING email AS next_email, (SELECT email AS prev_email FROM prev), - hugo_nominator, hugo_voter, preferred_name(p) as name`, - data), - data.email ? tx.oneOrNone(`SELECT key FROM Keys WHERE email=$(email)`, data) : {}, - tx.none(`INSERT INTO Log ${log.sqlValues}`, log) - ])) - .then(([{ hugo_nominator, hugo_voter, next_email, prev_email, name }, key]) => { - email = next_email; - if (next_email === prev_email) return {} - updateMailRecipient(db, prev_email); - return !hugo_nominator && !hugo_voter ? {} - : key ? { key: key.key, name } - : setKeyChecked(req, db, data.email).then(({ key }) => ({ key, name })); - }) - .then(({ key, name }) => !!(key && mailTask('hugo-update-email', { - email, - key, - memberId: data.id, - name - }))) - .then(key_sent => { - res.json({ status: 'success', updated: fields, key_sent }); - updateMailRecipient(db, email); - }) - .catch(err => (ppCond && !err[0].success && err[0].result.message == 'No data returned from the query.') - ? res.status(402).json({ status: 'error', message: 'Paper publications have not been enabled for this person' }) - : next(err)); + const query = ` + WITH prev AS ( + SELECT email, m.hugo_nominator, m.wsfs_member + FROM people p + LEFT JOIN membership_types m USING (membership) + WHERE id=$(id) + ) + UPDATE People p + SET ${fields.map(f => `${f}=$(${f})`).join(', ')} + WHERE id=$(id) ${ppCond} + RETURNING + email AS next_email, + preferred_name(p) as name, + (SELECT email AS prev_email FROM prev), + (SELECT hugo_nominator FROM prev), + (SELECT wsfs_member FROM prev)` + return { fields, ppCond, query, values } +} + +function updatePerson(req, res, next) { + const { fields, ppCond, query, values } = getUpdateQuery( + req.body, + parseInt(req.params.id), + req.session.user.member_admin + ) + const log = new LogEntry(req, 'Update fields: ' + fields.join(', ')) + log.subject = values.id + req.app.locals.db.task(dbTask => { + dbTask.tx(tx => tx.batch([ + tx.one(query, values), + values.email + ? tx.oneOrNone(`SELECT key FROM Keys WHERE email=$(email)`, values) + : {}, + tx.none(`INSERT INTO Log ${log.sqlValues}`, log) + ])) + .then(([{ hugo_nominator, wsfs_member, next_email, prev_email, name }, prevKey]) => { + values.email = next_email; + if (next_email !== prev_email) { + updateMailRecipient(dbTask, prev_email); + if (hugo_nominator || wsfs_member) { + return prevKey + ? { key: prevKey.key, name } + : setKeyChecked(req, dbTask, values.email) + .then(({ key }) => ({ key, name })); + } + } + return {} + }) + .then(({ key, name }) => !!( + key && + mailTask('hugo-update-email', { + email: values.email, + key, + memberId: values.id, + name + }) + )) + .then(key_sent => { + res.json({ status: 'success', updated: fields, key_sent }); + updateMailRecipient(dbTask, values.email); + }) + .catch(err => { + if (ppCond && Array.isArray(err) && !err[0].success) { + const { message } = err.result || {} + if (message === 'No data returned from the query.') { + err = new InputError('Paper publications have not been enabled for this person') + err.status = 402 + } + } + next(err) + }) + }) } diff --git a/kansa/lib/public.js b/kansa/lib/public.js index 113a4df..fb09ff5 100644 --- a/kansa/lib/public.js +++ b/kansa/lib/public.js @@ -1,9 +1,25 @@ +const config = require('./config'); const { AuthError, InputError } = require('./errors'); module.exports = { - getDaypassStats, getPublicPeople, getPublicStats, lookupPerson + getConfig, getDaypassStats, getPublicPeople, getPublicStats, lookupPerson }; +function getConfig(req, res, next) { + req.app.locals.db.any(` + SELECT membership, badge, hugo_nominator, member, wsfs_member + FROM membership_types` + ) + .then(rows => { + const membershipTypes = {} + rows.forEach(({ membership, ...props }) => { + membershipTypes[membership] = props + }) + res.json(Object.assign({ membershipTypes }, config)); + }) + .catch(next); +} + function getDaypassStats(req, res, next) { const csv = !!(req.query.csv); req.app.locals.db.any('SELECT * FROM daypass_stats') @@ -35,45 +51,50 @@ function getPublicPeople(req, res, next) { function getPublicStats(req, res, next) { const csv = !!(req.query.csv); req.app.locals.db.any('SELECT * from country_stats') - .then(data => { - if (csv) res.csv(data, true); - else res.json(data.reduce((map, c) => { - map[c.country] = Object.keys(c).reduce((cc, k) => { - if (typeof c[k] === 'number') cc[k] = c[k]; - return cc; - }, map[c.country] || {}); - return map; - }, {})); + .then(rows => { + if (csv) return res.csv(rows, true); + const data = {} + rows.forEach(({ country, membership, count }) => { + const c = data[country] + if (c) c[membership] = Number(count) + else data[country] = { [membership]: Number(count) } + }) + res.json(data); }) .catch(next); } -function lookupPerson(req, res, next) { - if (!req.session || !req.session.user || !req.session.user.email) return next(new AuthError()); - const { email, member_number, name } = req.body; - const queryParts = []; - const queryValues = {}; +function getLookupQuery({ email, member_number, name }) { + const parts = []; + const values = {}; if (email && /.@./.test(email)) { - queryParts.push('lower(email) = $(email)'); - queryValues.email = email.trim().toLowerCase(); + parts.push('lower(email) = $(email)'); + values.email = email.trim().toLowerCase(); } if (member_number > 0) { - queryParts.push('(member_number = $(number) OR id = $(number))'); - queryValues.number = Number(member_number); + parts.push('(member_number = $(number) OR id = $(number))'); + values.number = Number(member_number); } if (name) { - queryParts.push('(lower(legal_name) = $(name) OR lower(public_name(p)) = $(name))'); - queryValues.name = name.trim().toLowerCase(); + parts.push('(lower(legal_name) = $(name) OR lower(public_name(p)) = $(name))'); + values.name = name.trim().toLowerCase(); } - if (queryParts.length === 0 || (queryParts.length === 1 && queryValues.number)) { - return next(new InputError('No valid parameters')); + if (parts.length === 0 || (parts.length === 1 && values.number)) { + throw new InputError('No valid parameters'); } - req.app.locals.db.any(` + const query = ` SELECT id, membership, preferred_name(p) AS name - FROM people p - WHERE ${queryParts.join(' AND ')} - AND membership NOT IN ('Child', 'KidInTow')`, queryValues - ) + FROM people p + LEFT JOIN membership_types m USING (membership) + WHERE ${parts.join(' AND ')} AND + m.allow_lookup = true` + return { query, values } +} + +function lookupPerson(req, res, next) { + if (!req.session || !req.session.user || !req.session.user.email) return next(new AuthError()); + const { query, values } = getLookupQuery(req.body) + req.app.locals.db.any(query, values) .then(results => { switch (results.length) { case 0: return res.json({ status: 'not found' }); diff --git a/kansa/lib/purchase.js b/kansa/lib/purchase.js index 9dd66da..24d9dc3 100644 --- a/kansa/lib/purchase.js +++ b/kansa/lib/purchase.js @@ -132,9 +132,9 @@ class Purchase { } } - checkUpgrades(prices, reqUpgrades) { + checkUpgrades(db, prices, reqUpgrades) { if (reqUpgrades.length === 0) return Promise.resolve([]); - return this.db.any(` + return db.any(` SELECT id, email, membership, preferred_name(p) as name, paper_pubs FROM People p WHERE id IN ($1:csv)`, [reqUpgrades.map(u => u.id)] @@ -147,11 +147,9 @@ class Purchase { if (!prev || !prev.membership) throw new InputError(`Previous membership not found for ${JSON.stringify(upgrade)}`); if (!upgrade.membership || upgrade.membership === prev.membership) { delete upgrade.membership; - } else { - const ti0 = Person.membershipTypes.indexOf(prev.membership); - const ti1 = Person.membershipTypes.indexOf(upgrade.membership); - if (ti1 <= ti0) throw new InputError( - `Can't "upgrade" from ${JSON.stringify(prev.membership)} to ${JSON.stringify(upgrade.membership)}` + } else if (prices[upgrade.membership] < prices[prev.membership]) { + throw new InputError( + `Can't upgrade from ${JSON.stringify(prev.membership)} to ${JSON.stringify(upgrade.membership)}` ); } @@ -178,6 +176,126 @@ class Purchase { }); } + getMembershipPaymentItems(prices, newMembers, upgrades) { + const newMemberItems = newMembers.map(({ data }) => { + const mp = prices[data.membership] + if (typeof mp !== 'number' || mp < 0) { + throw new InputError(`Membership type not available for purchase: ${JSON.stringify(data.membership)}`) + } + const pp = config.paid_paper_pubs && data.paper_pubs && prices.paper_pubs || 0 + return { + amount: mp + pp, + category: 'new_member', + currency: 'eur', + data, + person_name: data.preferredName, + type: data.membership + } + }) + const upgradeItems = upgrades.map(({ amount, id, membership, name, paper_pubs}) => ({ + amount, + category: 'upgrade', + currency: 'eur', + data: { membership, paper_pubs: paper_pubs || undefined }, + person_id: id, + person_name: name, + type: 'upgrade' + })) + return newMemberItems.concat(upgradeItems) + } + + getMembershipPurchaseData(db, { + account, + amount: reqAmount, + email, + new_members: reqNewMembers = [], + source, + upgrades: reqUpgrades = [] + }) { + if (!email) throw new InputError('Required parameter: email') + if (!reqAmount !== !source) { + throw new InputError('If one is set, the other is required: amount, source') + } + if (reqNewMembers.length === 0 && reqUpgrades.length === 0) { + throw new InputError('Non-empty new_members or upgrades is required') + } + const newMembers = reqNewMembers.map(src => new Person(src)) + return db.any(` + SELECT key, amount + FROM payment_types WHERE category IN ('new_member', 'paper_pubs')` + ) + .then(rows => { + const prices = rows.reduce((prices, { key, amount }) => { + prices[key] = amount + return prices + }, {}) + return this.checkUpgrades(db, prices, reqUpgrades) + .then(upgrades => ({ prices, upgrades })) + }) + .then(({ prices, upgrades }) => { + const items = this.getMembershipPaymentItems(prices, newMembers, upgrades) + const amount = Number(reqAmount) + const calcAmount = items.reduce((sum, item) => sum + item.amount, 0) + if (amount !== calcAmount) { + throw new InputError(`Amount mismatch: in request ${amount}, calculated ${calcAmount}`) + } + return { account, amount, email, items, newMembers, prices, source, upgrades } + }) + } + + applyMembershipPurchase(req, db, paidItems, { charge_id, newMembers, upgrades }) { + const applyUpgrade = (u) => upgradePerson(req, db, u) + .then(({ member_number }) => { + u.member_number = member_number + return getKeyChecked(req, db, u.email) + }) + .then(({ key }) => mailTask( + ((!u.membership || u.membership === u.prev_membership) && u.paper_pubs) + ? 'kansa-add-paper-pubs' : 'kansa-upgrade-person', + Object.assign({ charge_id, key }, u) + )) + const applyNewMember = (m) => addPerson(req, db, m) + .then(() => { + const pi = paidItems.find(item => item.data === m.data) + return pi && db.none( + `UPDATE ${Payment.table} SET person_id=$1 WHERE id=$2`, [m.data.id, pi.id] + ); + }) + .then(() => getKeyChecked(req, db, m.data.email)) + .then(({ key, set }) => { + const data = Object.assign({ charge_id, key, name: m.preferredName }, m.data) + return mailTask('kansa-new-member', data) + .then(() => set ? data.email : null) + }) + return Promise.all( + upgrades.map(applyUpgrade).concat(newMembers.map(applyNewMember)) + ) + } + + makeMembershipPurchase(req, res, next) { + let data + return this.db.task(dbTask => + this.getMembershipPurchaseData(dbTask, req.body) + .then(d => { + data = d + if (data.amount === 0) return [] + const { account, email, source, items } = data + return new Payment(this.pgp, dbTask, account, email, source, items).process() + }) + .then(paidItems => { + if (paidItems[0]) data.charge_id = paidItems[0].stripe_charge_id + return this.applyMembershipPurchase(req, dbTask, paidItems, data) + }) + .then(newEmails => { + if (!req.session.user) { + const email = newEmails.find(e => e) + if (email) req.session.user = { email, roles: {} }; + } + res.json({ status: 'success', charge_id: data.charge_id }); + }) + ).catch(next); + } + makeDaypassPurchase(req, res, next) { const amount = Number(req.body.amount) const { email, passes, source } = req.body @@ -232,98 +350,6 @@ class Purchase { }).catch(next) } - makeMembershipPurchase(req, res, next) { - const amount = Number(req.body.amount); - const { account, email, source } = req.body; - if (!email) return next(new InputError('Required parameter: email')); - if (!amount !== !source) return next(new InputError('If one is set, the other is required: amount, source')); - const newMembers = (req.body.new_members || []).map(src => new Person(src)); - const reqUpgrades = req.body.upgrades || []; - if (newMembers.length === 0 && reqUpgrades.length === 0) return next( - new InputError('Non-empty new_members or upgrades is required') - ); - const newEmailAddresses = {}; - let charge_id, paymentItems, prices, upgrades; - return this.db.any(` - SELECT key, amount - FROM payment_types - WHERE category IN ('new_member', 'paper_pubs') - `).then(priceRows => { - prices = priceRows.reduce((p, { key, amount }) => { - p[key] = amount - return p - }, {}) - return this.checkUpgrades(prices, reqUpgrades) - }).then(_upgrades => { - upgrades = _upgrades; - const newMemberPaymentItems = newMembers.map(p => { - const mp = prices[p.data.membership] || 0 - const pp = config.paid_paper_pubs && p.data.paper_pubs && prices.paper_pubs || 0 - return { - amount: mp + pp, - currency: 'eur', - category: 'new_member', - person_name: p.data.preferredName, - type: p.data.membership, - data: p.data - } - }); - const upgradePaymentItems = upgrades.map(u => ({ - amount: u.amount, - currency: 'eur', - person_id: u.id, - person_name: u.name, - category: 'upgrade', - type: 'upgrade', - data: { membership: u.membership, paper_pubs: u.paper_pubs || undefined }, - })); - const items = newMemberPaymentItems.concat(upgradePaymentItems); - const calcAmount = items.reduce((sum, item) => sum + item.amount, 0); - if (amount !== calcAmount) throw new InputError(`Amount mismatch: in request ${amount}, calculated ${calcAmount}`); - return amount === 0 ? [] : new Payment(this.pgp, this.db, account, email, source, items) - .process() - }).then(_items => { - paymentItems = _items; - if (_items[0]) charge_id = _items[0].stripe_charge_id - return Promise.all(upgrades.map(u => ( - upgradePerson(req, this.db, u) - .then(({ member_number }) => { - u.member_number = member_number; - return getKeyChecked(req, this.db, u.email); - }) - .then(({ key }) => mailTask( - ((!u.membership || u.membership === u.prev_membership) && u.paper_pubs) - ? 'kansa-add-paper-pubs' : 'kansa-upgrade-person', - Object.assign({ charge_id, key }, u) - )) - ))); - }).then(() => Promise.all( - newMembers.map(m => ( - addPerson(req, this.db, m) - .then(() => { - const pi = paymentItems.find(item => item.data === m.data); - return pi && this.db.none( - `UPDATE ${Payment.table} SET person_id=$1 WHERE id=$2`, [m.data.id, pi.id] - ); - }) - .then(() => getKeyChecked(req, this.db, m.data.email)) - .then(({ key, set }) => { - if (set) newEmailAddresses[m.data.email] = true; - return mailTask( - 'kansa-new-member', - Object.assign({ charge_id, key, name: m.preferredName }, m.data) - ); - }) - )) - )).then(() => { - if (!req.session.user) { - const nea = Object.keys(newEmailAddresses); - if (nea.length >= 1) req.session.user = { email: nea[0], roles: {} }; - } - res.status(200).json({ status: 'success', charge_id }); - }).catch(next); - } - makeOtherPurchase(req, res, next) { const { account, email, items, source } = req.body; new Payment(this.pgp, this.db, account, email, source, items) diff --git a/kansa/lib/siteselect.js b/kansa/lib/siteselect.js index 7d91978..9f96a59 100644 --- a/kansa/lib/siteselect.js +++ b/kansa/lib/siteselect.js @@ -83,37 +83,41 @@ class Siteselect { const { id } = req.params const token = Siteselect.parseToken(req.body.token) let { voter_name, voter_email } = req.body - this.db.tx(tx => tx.sequence((i, data) => { switch (i) { - case 0: - return token ? tx.one(`SELECT used FROM tokens WHERE token=$1`, token) : {} - - case 1: - if (!data) throw new InputError(`Token not found`) - if (data.used) throw new InputError(`Token already used at ${data.used}`) - return this.db.oneOrNone(` - SELECT p.legal_name, p.email, s.time AS vote_time - FROM people p LEFT JOIN siteselection_votes s ON (p.id = s.person_id) - WHERE p.id = $1 AND p.membership IN - ('Supporter','Youth','FirstWorldcon','Adult')`, id) - - case 2: - if (!data) throw new InputError('Voter not found') - if (data.vote_time) throw new InputError(`Already voted at ${data.vote_time}`) - if (!voter_name && !voter_email) { - voter_name = data.legal_name - voter_email = data.email - } - this.db.none(` - INSERT INTO siteselection_votes (person_id, token, voter_name, voter_email) - VALUES ($(id), $(token), $(voter_name), $(voter_email))`, - { - id, - token: token || null, - voter_name: voter_name || null, - voter_email: voter_email || null + this.db.task(dbTask => + ( + token + ? dbTask.oneOrNone(`SELECT used FROM tokens WHERE token=$1`, token) + : Promise.resolve({}) + ) + .then(data => { + if (!data) throw new InputError(`Token not found`) + if (data.used) throw new InputError(`Token already used at ${data.used}`) + return dbTask.oneOrNone(` + SELECT p.legal_name, p.email, s.time AS vote_time + FROM people p + LEFT JOIN siteselection_votes s ON (p.id = s.person_id) + LEFT JOIN membership_types m USING (membership) + WHERE p.id = $1 AND m.wsfs_member = true`, id) + }) + .then(data => { + if (!data) throw new InputError('Voter not found') + if (data.vote_time) throw new InputError(`Already voted at ${data.vote_time}`) + if (!voter_name && !voter_email) { + voter_name = data.legal_name + voter_email = data.email } - ) - }})) + return dbTask.none(` + INSERT INTO siteselection_votes (person_id, token, voter_name, voter_email) + VALUES ($(id), $(token), $(voter_name), $(voter_email))`, + { + id, + token: token || null, + voter_name: voter_name || null, + voter_email: voter_email || null + } + ) + }) + ) .then(() => res.json({ status: 'success', voter_name, voter_email })) .catch(next) } diff --git a/kansa/lib/slack.js b/kansa/lib/slack.js index f07a529..8436dbc 100644 --- a/kansa/lib/slack.js +++ b/kansa/lib/slack.js @@ -32,8 +32,12 @@ function sendInvite(org, data) { function getUserData(db, session) { const email = session && session.user && session.user.email if (!email) return Promise.reject(new AuthError()) - let select = `SELECT public_first_name, public_last_name FROM People WHERE email = $1` - if (process.env.SLACK_REQ_MEMBER) select += ` AND membership != 'NonMember'` + let select = ` + SELECT public_first_name, public_last_name + FROM People p + LEFT JOIN membership_types m USING (membership) + WHERE email = $1` + if (process.env.SLACK_REQ_MEMBER) select += ` AND m.member = true` return db.any(select, email).then(people => { if (people.size === 0) throw new AuthError('Slack access requires membership') const user = { email } diff --git a/kansa/lib/types/person.js b/kansa/lib/types/person.js index 258e8f3..4a79c36 100644 --- a/kansa/lib/types/person.js +++ b/kansa/lib/types/person.js @@ -1,3 +1,4 @@ +const { InputError } = require('../errors'); const util = require('../util'); class Person { @@ -6,68 +7,46 @@ class Person { // id SERIAL PRIMARY KEY 'last_modified', // timestamptz DEFAULT now() 'legal_name', // text NOT NULL - 'membership', // MembershipStatus NOT NULL + 'membership', // text NOT NULL REFERENCES membership_types 'member_number', // integer 'public_first_name', 'public_last_name', // text 'email', // text 'city', 'state', 'country', // text 'badge_name', 'badge_subtitle', // text - 'hugo_nominator', 'hugo_voter', // bool 'paper_pubs', // jsonb 'daypass', // string 'daypass_days' // int[] ]; } - static get boolFields() { - return [ 'hugo_nominator', 'hugo_voter' ]; - } - - static hugoVoterType(membership) { - return [ 'Supporter', 'Youth', 'FirstWorldcon', 'Adult' ].indexOf(membership) !== -1 - } - static get userModFields() { return [ 'legal_name', 'public_first_name', 'public_last_name', 'city', 'state', 'country', 'badge_name', 'badge_subtitle', 'paper_pubs' ]; } - static get membershipTypes() { - return [ 'NonMember', 'Exhibitor', 'Helper', 'Supporter', 'KidInTow', 'Child', 'Youth', 'FirstWorldcon', 'Adult' ]; - } - static get paperPubsFields() { return [ 'name', 'address', 'country' ]; // text } - static cleanMemberType(ms) { - if (Person.membershipTypes.indexOf(ms) > -1) return ms; - throw new Error('Invalid membership type: ' + JSON.stringify(ms)); - } - static cleanPaperPubs(pp) { if (!util.isTrueish(pp)) return null; if (typeof pp == 'string') pp = JSON.parse(pp); return Person.paperPubsFields.reduce((o, fn) => { - if (!pp[fn]) throw new Error('If non-null, paper_pubs requires: ' + Person.paperPubsFields.join(', ')); + if (!pp[fn]) throw new InputError('If non-null, paper_pubs requires: ' + Person.paperPubsFields.join(', ')); o[fn] = pp[fn]; return o; }, {}); } constructor(src) { - if (!src || !src.legal_name || !src.membership) throw new Error('Missing data for new Person (required: legal_name, membership)'); + if (!src || !src.legal_name || !src.membership) { + throw new InputError('Missing data for new Person (required: legal_name, membership)'); + } this.data = Object.assign({}, src); - Person.cleanMemberType(this.data.membership); - Person.boolFields.forEach(fn => util.forceBool(this.data, fn)); util.forceInt(this.data, 'member_number'); if (this.data.membership === 'NonMember') this.data.member_number = null; this.data.paper_pubs = Person.cleanPaperPubs(this.data.paper_pubs); } - get hugoVoterType() { - return Person.hugoVoterType(this.data.membership) - } - get passDays() { return Object.keys(this.data).filter(key => /^day\d+$/.test(key) && this.data[key]) } diff --git a/kansa/lib/upgrade.js b/kansa/lib/upgrade.js index ec3d7cf..63cf60a 100644 --- a/kansa/lib/upgrade.js +++ b/kansa/lib/upgrade.js @@ -5,31 +5,18 @@ const { updateMailRecipient } = require('./mail'); module.exports = { authUpgradePerson, upgradePerson }; -function verifyUpgrade(data) { - const checks = { - membership: Person.cleanMemberType, - paper_pubs: Person.cleanPaperPubs - }; - for (const key in checks) { - if (data.hasOwnProperty(key)) try { - data[key] = checks[key](data[key]); - } catch (e) { - throw new Error(`${key}: ${e.message}`); - } - } - if (data.membership === 'NonMember') throw new Error(`Can't "upgrade" to NonMember`); -} - function upgradePaperPubs(req, db, data) { if (!data.paper_pubs) throw new InputError('No valid parameters'); const log = new LogEntry(req, 'Add paper pubs'); return db.tx(tx => tx.batch([ tx.one(` - UPDATE People - SET paper_pubs=$(paper_pubs) - WHERE id=$(id) AND membership != 'NonMember' - RETURNING member_number`, - data), + UPDATE People p + SET paper_pubs=$(paper_pubs) + FROM membership_types m + WHERE id=$(id) AND + m.membership = p.membership AND + m.member = true + RETURNING member_number`, data), tx.none(`INSERT INTO Log ${log.sqlValues}`, log) ])) .then((results) => ({ @@ -47,53 +34,66 @@ function upgradePaperPubs(req, db, data) { }); } -function upgradeMembership(req, db, data) { - const set = [ 'membership=$(membership)' ]; - let email, member_number; - return db.tx(tx => tx.sequence((i, prev) => { switch (i) { - - case 0: - return tx.one(` - SELECT membership, member_number - FROM People - WHERE id=$1`, - data.id); - - case 1: - const prevTypeIdx = Person.membershipTypes.indexOf(prev.membership); - const nextTypeIdx = Person.membershipTypes.indexOf(data.membership); - if (nextTypeIdx <= prevTypeIdx) throw new InputError(`Can't "upgrade" from ${prev.membership} to ${data.membership}`); - if (!parseInt(prev.member_number)) set.push("member_number=nextval('member_number_seq')"); - if (data.paper_pubs) set.push('paper_pubs=$(paper_pubs)'); - return tx.one(` - UPDATE People - SET ${set.join(', ')} - WHERE id=$(id) - RETURNING email, member_number`, data); - - case 2: - email = prev.email; - member_number = prev.member_number; - const log = new LogEntry(req, `Upgrade to ${data.membership}`); - if (data.paper_pubs) log.description += ' and add paper pubs'; - log.subject = data.id; - return tx.none(`INSERT INTO Log ${log.sqlValues}`, log); - - case 3: - updateMailRecipient(tx, email); +function getUpgradeQuery(data, addMemberNumber) { + const fields = ['membership'] + let update = 'membership=$(membership)' + if (addMemberNumber) { + fields.push('member_number') + update += ", member_number=nextval('member_number_seq')" + } + if (data.paper_pubs) { + fields.push('paper_pubs') + update += ', paper_pubs=$(paper_pubs)' + } + const query = ` + UPDATE people SET ${update} WHERE id=$(id) + RETURNING email, member_number` + return { fields, query } +} - }})) - .then(() => ({ - member_number, - updated: set.map(sql => sql.replace(/=.*/, '')) - })); +function upgradeMembership(req, db, data) { + return db.task(dbTask => + dbTask.batch([ + dbTask.any(`SELECT * FROM membership_prices`), + dbTask.one(`SELECT membership, member_number FROM People WHERE id=$1`, data.id) + ]) + .then(([priceRows, prev]) => { + const nextPrice = priceRows.find(p => p.membership === data.membership) + if (!nextPrice) { + const strType = JSON.stringify(data.membership) + throw new InputError(`Invalid membership type: ${strType}`) + } + const prevPrice = priceRows.find(p => p.membership === prev.membership) + if (prevPrice && prevPrice.amount > nextPrice.amount) { + throw new InputError(`Can't upgrade from ${prev.membership} to ${data.membership}`) + } + const addMemberNumber = !parseInt(prev.member_number) + const { fields, query } = getUpgradeQuery(data, addMemberNumber) + return dbTask.tx(tx => + tx.one(query, data) + .then(({ email, member_number }) => { + const log = new LogEntry(req, `Upgrade to ${data.membership}`) + if (data.paper_pubs) log.description += ' and add paper pubs' + log.subject = data.id + return tx.none(`INSERT INTO Log ${log.sqlValues}`, log) + .then(() => ({ email, fields, member_number })) + }) + ) + }) + .then(({ email, fields, member_number }) => { + updateMailRecipient(dbTask, email) + return { member_number, updated: fields } + }) + ) } function upgradePerson(req, db, data) { - try { - verifyUpgrade(data); - } catch (err) { - return Promise.reject(err) + if (data.hasOwnProperty('paper_pubs')) { + try { + data.paper_pubs = Person.cleanPaperPubs(data.paper_pubs); + } catch (err) { + return Promise.reject(err) + } } return data.membership ? upgradeMembership(req, db, data) diff --git a/postgres/init/22-kansa-tables.sql b/postgres/init/22-kansa-tables.sql index e184987..519093c 100644 --- a/postgres/init/22-kansa-tables.sql +++ b/postgres/init/22-kansa-tables.sql @@ -2,10 +2,20 @@ SET ROLE kansa; CREATE SEQUENCE member_number_seq START 10; +CREATE TABLE membership_types ( + membership text PRIMARY KEY, + allow_lookup bool, + badge bool, + daypass_available bool, + hugo_nominator bool, + member bool, + wsfs_member bool +); + CREATE TABLE IF NOT EXISTS People ( id SERIAL PRIMARY KEY, last_modified timestamptz DEFAULT now(), - membership MembershipStatus NOT NULL, + membership text NOT NULL REFERENCES membership_types, member_number integer UNIQUE DEFAULT nextval('member_number_seq'), legal_name text NOT NULL, public_first_name text, @@ -16,8 +26,6 @@ CREATE TABLE IF NOT EXISTS People ( country text, badge_name text, badge_subtitle text, - hugo_nominator bool, - hugo_voter bool, paper_pubs jsonb ); diff --git a/postgres/init/25-day-passes.sql b/postgres/init/25-day-passes.sql index aa6e71f..34ca0e5 100644 --- a/postgres/init/25-day-passes.sql +++ b/postgres/init/25-day-passes.sql @@ -1,7 +1,7 @@ SET ROLE kansa; CREATE TABLE daypass_amounts ( - status MembershipStatus PRIMARY KEY, + status text PRIMARY KEY REFERENCES membership_types, day1 integer, day2 integer, day3 integer, @@ -12,7 +12,7 @@ CREATE TABLE daypass_amounts ( CREATE TABLE daypasses ( id SERIAL PRIMARY KEY, person_id integer REFERENCES people NOT NULL, - status MembershipStatus NOT NULL, + status text NOT NULL REFERENCES membership_types, day1 bool DEFAULT false, day2 bool DEFAULT false, day3 bool DEFAULT false, @@ -23,7 +23,7 @@ CREATE TABLE daypasses ( CREATE TABLE badge_and_daypass_prints ( person integer REFERENCES people NOT NULL, timestamp timestamptz NOT NULL DEFAULT now(), - membership MembershipStatus NOT NULL, + membership text NOT NULL REFERENCES membership_types, member_number integer, daypass integer REFERENCES daypasses ); diff --git a/postgres/init/25-payments.sql b/postgres/init/25-payments.sql index 32d56db..b2fce74 100644 --- a/postgres/init/25-payments.sql +++ b/postgres/init/25-payments.sql @@ -88,3 +88,8 @@ CREATE TABLE stripe_keys ( type StripeKeyType NOT NULL, key text NOT NULL ); + +CREATE VIEW membership_prices AS + SELECT m.membership, p.amount + FROM membership_types m LEFT JOIN payment_types p ON (m.membership = p.key) + WHERE p.category = 'new_member' or p.category IS NULL; diff --git a/postgres/init/25-public-data.sql b/postgres/init/25-public-data.sql index 89bcf61..8a75ce5 100644 --- a/postgres/init/25-public-data.sql +++ b/postgres/init/25-public-data.sql @@ -54,18 +54,15 @@ BEGIN END; $$ LANGUAGE plpgsql; -CREATE VIEW country_stats AS SELECT * FROM crosstab( - 'SELECT coalesce(country(country),''=''), - coalesce(membership::text,''=''), - count(*) - FROM People WHERE membership != ''NonMember'' - GROUP BY CUBE(country(country), membership)', - $$VALUES ('Adult'), ('FirstWorldcon'), ('Youth'), ('Child'), ('KidInTow'), ('Exhibitor'), ('Supporter'), ('=') $$ -) AS ct ( - country text, - "Adult" int, "FirstWorldcon" int, "Youth" int, "Child" int, "KidInTow" int, "Exhibitor" int, "Supporter" int, - "=" int -); +CREATE VIEW country_stats AS + SELECT + coalesce(country(country), '=') AS country, + coalesce(membership, '=') AS membership, + count(*) + FROM People p + LEFT JOIN membership_types m USING (membership) + WHERE m.member = true + GROUP BY CUBE(country(country), membership); CREATE VIEW daypass_stats AS SELECT * FROM crosstab( 'SELECT status, ''Wed'' AS day, count(*) @@ -95,10 +92,13 @@ CREATE VIEW daypass_stats AS SELECT * FROM crosstab( ); CREATE VIEW public_members AS - SELECT country(country), membership, - public_last_name AS last_name, - public_first_name AS first_name - FROM people - WHERE membership != 'NonMember' AND - (public_first_name != '' OR public_last_name != '') - ORDER BY last_name, first_name, country; + SELECT + country(country), + membership, + public_last_name AS last_name, + public_first_name AS first_name + FROM people p + LEFT JOIN membership_types m USING (membership) + WHERE m.member = true AND + (public_first_name != '' OR public_last_name != '') + ORDER BY last_name, first_name, country;