convention tech: forms and dashboard

the 2025 dsa national convention, on the back end

work plan

planning began approximately 18 months before the convention with a work plan submitted to the national political committee, drafted by myself and colin m.

link

after approval, work began on building the infrastructure on the national discussion forum.

custom forms

custom forms were created to allow members to submit proposals to the convention hub. using liquid template language, the submissions were formatted and automatically posted as a topic in a category on the convention hub.

sample code:

<div data-theme-toc="true"> </div>

# w{step_1_field_1}
Topic created by: @u{username}

# Author information
Submitted by: w{step_1_field_3}
Forum usernames: @w{step_1_field_4}

{% unless "w{step_2_field_3}" == empty %}
Submission also viewable at: 
w{step_2_field_3}
{% endunless %}

{%if "w{step_1_field_7}" contains "true" %}
  **Contact the author by email:** w{step_1_field_5}
{% endif %}
{%if "w{step_1_field_8}" contains "true" %}
  **Contact the author by phone:** tel:w{step_1_field_6}
{% endif %}
{%if "w{step_1_field_9}" contains "true" %}
  **Contact the author by sending a direct message on the forum:** @u{username}
{% endif %}

# Short Description by Author

w{step_2_field_1}

---

# Full Resolution Text 

w{step_2_field_2}

---
# Sign On

[poll name=sign_on type=regular results=always public=true chartType=bar]
# Indicate below if you'd like to sign on
* Sign-On
[/poll]

---

[poll name=opt_out type=regular results=staff_only public=false chartType=bar]
# Indicate below if you'd like to be opted out of communication from the authors
* Opt-Out
[/poll]

# Discussion
**NOTE: Discussion is locked in Proposal areas. If you'd like to discuss this head over to the Discussion Area on the Forum, linked below:** 

https://discussion.dsausa.org/c/convention-2025/con25-discuss/215

convention dashboard

using appsmith, an open source ntc-hosted tool, i created a convention dashboard to allow members to review data about each proposal, view survey results, and track their sign-ons.

the dashboard pulled data from the national discussion forum's api and was displayed using appsmith widgets and javascript.

member sign-on dashboard

members signed onto proposals using the polls feature on the discussion forum, so there was not a built-in easy way for members to view all of their signed-on proposals in one place. to solve that issue, i created the My Sign-Ons page on the convention dashboard.

i wanted to prevent a situation where any member could search any other member's proposal sign-ons. because there was no personally identifying information being transmitted or stored, there was no need to develop a robust hardened log in method - a simple token would do. because the convention dashboard was already presented to members as a static html page on the discussion forum, any user would necessarily already have an account on the forum. so, i created a system whereby the user is sent a token and must enter it into the dashboard in order to view their sign-ons.

to begin, users entered their discussion forum username and clicked Send Token.

this executed a js function which generates a simple 8 character token and sends it via a direct message to the user on the discussion forum, and the user inputs that token back on the sign-on dashboard, which is verified.

sample code:

export default {
	// --- internal state (only for non-persistent UI feedback) ---
	errorMessage: '',
	isLoading: false,
	// NOTE: verificationStep, usernameToVerify, generatedToken, verifiedProposalData
	// are now primarily managed via appsmith.store for session persistence.

	// function to start verification (send PM & save to session store)
	initiateVerification: async function() {
		console.log("initiating verification...");
		this.isLoading = true;
		this.errorMessage = '';
		// clear/initialize relevant store values at the start (persist: false for session storage)
		await storeValue('verificationStep', 'START', false);
		await storeValue('generatedVerificationToken', null, false);
		await storeValue('usernameBeingVerified', null, false);
		await storeValue('verifiedProposalData', [], false); // clear old verified data

		// get username from input
		const username = InputRecipientUsername.text.trim();
		if (!username) {
			showAlert("please enter a discussion forum username.", "warning");
			this.isLoading = false;
			return;
		}

		// generate token
		const token = TokenGenerator.generateRandomString(8);
		console.log(`generated token ${token} for ${username}`);

		// prepare pm content
		const pmSubject = "Your Convention Dashboard Verification Token";
		const pmBody = `please enter this verification token in the convention dashboard to complete verification:\n\n${token}\n\nif you did not request this, please ignore this message.`;

		// run the pm query
		try {
			console.log(`attempting to send PM to ${username}...`);
			await sendDiscoursePM.run({
				pmTitle: pmSubject,
				pmBody: pmBody,
				recipientUsername: username
			});

			// update state on SUCCESS using storeValue (persist: false)
			await storeValue('verificationStep', 'PENDING_TOKEN', false);
			await storeValue('generatedVerificationToken', token, false);
			await storeValue('usernameBeingVerified', username, false);
			console.log(`SUCCESS: state saved to store - step: PENDING_TOKEN, token: ${token}`);
			showAlert(`verification PM sent to ${username}! please check your discussion forum messages and enter the token below.`, 'success', 5000);

		} catch (error) {
			console.error("failed to send PM:", error);
			this.errorMessage = `failed to send PM to ${username}. check username or API setup. error: ${error.message}`;
			await storeValue('verificationStep', 'ERROR', false); // set error state in store
			await storeValue('generatedVerificationToken', null, false); // clear token from store
			await storeValue('usernameBeingVerified', null, false); // clear user from store
			console.log(`ERROR: verification step set to: ERROR, token cleared from store.`);
			showAlert(this.errorMessage, 'error');
		} finally {
			this.isLoading = false;
		}
	}, // end initiateVerification


	// function to verify token (includes optoutAll query run)
	verifyToken: async function() {
		console.log("verifying token...");
		this.isLoading = true;
		this.errorMessage = '';
		const enteredToken = InputToken.text.trim(); 

		// read state from session store
		const currentStep = appsmith.store.verificationStep;
		const storedToken = appsmith.store.generatedVerificationToken;
		const storedUsername = appsmith.store.usernameBeingVerified;
		console.log("state read from store - step:", currentStep, "token:", storedToken);

		if (!enteredToken) { showAlert("please enter the token you received.", "warning"); this.isLoading = false; return; }
		if (!storedToken || currentStep !== 'PENDING_TOKEN') { /* ... show error ... */ this.isLoading = false; return; }

		// compare tokens
		if (enteredToken === storedToken) {
			// SUCCESS
			console.log("token verified successfully!");
			await storeValue('verificationStep', 'VERIFIED', false);
			showAlert(`verification successful for ${storedUsername}! loading data...`, 'info', 3000);

			try {
				// 1. run getUser query
				console.log("running getUser query...");
				await getUser.run(); 
				console.log("getUser query successful.");

				// 2. run resoGet query
				console.log("running resoGet query...");
				await resoGet.run(); 
				console.log("resoGet query successful.");

				// 3. run optoutAll query
				console.log("running optoutAll query...");
				await optoutAll.run(); 
				console.log("optoutAll query successful.");

				// 4. transform optoutAll data
				let processedOptoutData = []; // default to empty array
				const rawOptoutData = optoutAll.data;
				if (rawOptoutData && Array.isArray(rawOptoutData.columns) && Array.isArray(rawOptoutData.rows)) {
					const titleIdx = rawOptoutData.columns.indexOf('title');
					const userIdx = rawOptoutData.columns.indexOf('username'); // verify column name

					if (titleIdx > -1 && userIdx > -1) {
						processedOptoutData = rawOptoutData.rows.map(row => ({
							title: row[titleIdx],
							username: row[userIdx]
						})).filter(item => item.title && item.username); // filter out invalid entries
						console.log(`processed ${processedOptoutData.length} opt-out records.`);
					} else {
						console.error("could not find 'title' or 'username' columns in optoutAll data.");
					}
				} else {
					console.warn("optoutAll data not available or not in expected columns/rows format.");
				}
				// store the transformed opt-out data
				await storeValue('optoutData', processedOptoutData, false);
				console.log("stored processed opt-out data to store.");


				// 5. get combined/mapped proposal data using ResolutionProcessor
				console.log("getting mapped data from ResolutionProcessor...");
				const mappedData = ResolutionProcessor.getMappedData(); // uses getUser.data & resoGet.data

				// 6. store the final data for the table in the session store
				await storeValue('verifiedProposalData', mappedData || [], false);
				console.log(`stored ${ (mappedData || []).length } items in store for verifiedProposalData.`);

				showAlert(`verification and data loading complete!`, 'success');

			} catch (fetchError) {
				console.error("error running queries or processing data after verification:", fetchError);
				this.errorMessage = "verification succeeded, but failed loading subsequent data.";
				await storeValue('verificationStep', 'ERROR_POST_VERIFY', false);
				await storeValue('verifiedProposalData', [], false);
				await storeValue('optoutData', [], false); // clear opt-out data on error
				showAlert(this.errorMessage, 'error');
			} finally {
				this.isLoading = false;
			}

		} else {
			// FAILURE (token mismatch)
			this.errorMessage = "invalid token entered. please try again.";
			console.warn("token verification failed.");
			showAlert(this.errorMessage, 'error');
			this.isLoading = false;
		}
	}, // end verifyToken


	// function to reset state (clear session store keys)
	resetState: async function() {
		console.log("resetting verifier session state...");
		// clear store values using persist: false
		await storeValue('verificationStep', 'START', false);
		await storeValue('generatedVerificationToken', null, false);
		await storeValue('usernameBeingVerified', null, false);
		await storeValue('verifiedProposalData', [], false);
		// reset internal state
		this.errorMessage = '';
		this.isLoading = false;
		// reset the token input field as well
		await resetWidget('InputToken', true);
		console.log("verifier session state reset.");
		// optionally show alert
		// showAlert('verification state reset.', 'info');
	} // end resetState
} // end export default

to display the member's signed-on proposals, an api call is made to a specific sql query on the discussion forum, using the member's forum username as a query parameter.

-- [params]
-- string :username

select u.username as username, t.title as title, pv.created_at as vote_time, REPLACE(c.name, 'Convention 2025 ', '') as category, pv.user_id as user_id, CONCAT('https://discussion.dsausa.org/t/',t.slug,'/',t.id) as url
from poll_votes pv
join users u on pv.user_id = u.id
join polls p on pv.poll_id = p.id
join posts po on p.post_id = po.id
join topics t on po.topic_id = t.id
join categories c on t.category_id = c.id
where t.category_id in (210,211,212)
and po.post_number = 1
and po.deleted_at is NULL
and p.name = 'sign_on'
and u.username = :username