@stacksjs/forms 0.71.2 → 0.71.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conditions.js +1 -0
- package/dist/definition.js +1 -0
- package/dist/index.js +1 -2
- package/dist/notifications.js +6 -0
- package/dist/submissions.js +2 -0
- package/dist/types.js +0 -0
- package/dist/validate.js +1 -0
- package/package.json +6 -6
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function evaluateConditions(conditions,values){if(!conditions||conditions.rules.length===0)return!0;const results=conditions.rules.map((rule)=>{const actual=values[rule.field];switch(rule.op){case"eq":return String(actual??"")===String(rule.value??"");case"neq":return String(actual??"")!==String(rule.value??"");case"contains":return String(actual??"").includes(String(rule.value??""));case"gt":return Number(actual)>Number(rule.value);case"lt":return Number(actual)<Number(rule.value);case"empty":return actual===void 0||actual===null||String(actual)==="";case"not_empty":return actual!==void 0&&actual!==null&&String(actual)!=="";default:return!1}}),matched=conditions.logic==="all"?results.every(Boolean):results.some(Boolean);return conditions.action==="show"?matched:!matched}export function visibleFields(fields,values){return fields.filter((field)=>evaluateConditions(field.conditions,values)).sort((a,b)=>a.position-b.position)}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{db}from"@stacksjs/database";function parseJson(raw,fallback){if(raw&&typeof raw==="object")return raw;if(typeof raw==="string"&&raw)try{return JSON.parse(raw)}catch{return fallback}return fallback}function rowToField(row){return{name:row.name,label:row.label,type:row.type,required:row.required===!0||row.required===1,position:Number(row.position??0),width:row.width==="half"?"half":"full",options:parseJson(row.options,{}),conditions:parseJson(row.conditions,null)}}export async function loadFormByUuid(uuid,siteId){const form=await db.selectFrom("forms").where("uuid","=",uuid).select(["id","uuid","site_id","name","handle","status","settings"]).executeTakeFirst();if(!form)return null;if(siteId!==null&&form.site_id!==null&&Number(form.site_id)!==siteId)return null;const fields=await db.selectFrom("form_fields").where("form_id","=",form.id).select(["name","label","type","required","position","width","options","conditions"]).orderBy("position","asc").execute();return{id:Number(form.id),uuid:form.uuid,siteId:form.site_id==null?null:Number(form.site_id),name:form.name,handle:form.handle,status:["draft","active","closed"].includes(form.status)?form.status:"draft",settings:parseJson(form.settings,{}),fields:fields.map(rowToField)}}export function publicDefinition(form){return{uuid:form.uuid,name:form.name,status:form.status,submitLabel:form.settings.submitLabel??"Submit",confirmation:form.settings.confirmation?.type==="message"?form.settings.confirmation.value:null,payment:form.settings.payment?{mode:form.settings.payment.mode,amountCents:form.settings.payment.mode==="fixed"?form.settings.payment.amountCents??0:void 0,currency:form.settings.payment.currency??"USD",minAmountCents:form.settings.payment.minAmountCents}:null,fields:form.fields.sort((a,b)=>a.position-b.position).map((field)=>({name:field.name,label:field.label,type:field.type,required:field.required,width:field.width,options:{placeholder:field.options.placeholder,choices:field.options.choices,min:field.options.min,max:field.options.max,accept:field.options.accept},conditions:field.conditions}))}}
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export{t as visibleFields,a as validateSubmission,f as submitForm,n as publicDefinition,m as loadFormByUuid,d as fetchSubmissions,b as exportSubmissionsCsv,s as evaluateConditions,u as dispatchSubmissionNotifications,S as computeAmountCents,l as completeSubmissionPayment};
|
|
1
|
+
export{evaluateConditions,visibleFields}from"./conditions";export{loadFormByUuid,publicDefinition}from"./definition";export{dispatchSubmissionNotifications}from"./notifications";export{completeSubmissionPayment,exportSubmissionsCsv,fetchSubmissions,submitForm}from"./submissions";export{computeAmountCents,validateSubmission}from"./validate";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import{log}from"@stacksjs/logging";import{notify}from"@stacksjs/notifications";export async function dispatchSubmissionNotifications(form,result,submission){const summary=Object.entries(submission.values).map(([key,value])=>`${key}: ${String(value)}`).join(`
|
|
2
|
+
`);for(const staffEmail of form.settings.notifyEmails??[])try{await notify({email:staffEmail},{subject:`New ${form.name} submission`,body:`${submission.name??"Someone"}${submission.email?` <${submission.email}>`:""} submitted "${form.name}".
|
|
3
|
+
|
|
4
|
+
${summary}`},["email"],{ignorePreferences:!0,category:"forms"})}catch(error){log.error(`[forms] staff notification to ${staffEmail} failed: ${error.message}`)}if(submission.email&&result.status==="complete")try{await notify({email:submission.email},{subject:`We received your ${form.name}`,body:`Thanks${submission.name?`, ${submission.name}`:""} - your ${form.name} was received.${result.confirmation?`
|
|
5
|
+
|
|
6
|
+
${result.confirmation}`:""}`},["email"],{ignorePreferences:!0,category:"forms"})}catch(error){log.error(`[forms] confirmation to ${submission.email} failed: ${error.message}`)}}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{randomUUID}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database";import{validateSubmission}from"./validate";export async function submitForm(form,payload,options={}){if(form.status!=="active")return{ok:!1,status:409,message:"This form is not accepting responses."};const spam=config.forms?.spam??{};if((spam.honeypot??!0)&&typeof options.honeypot==="string"&&options.honeypot.trim()!=="")return{ok:!0,submissionId:0,submissionUuid:"",status:"complete",amountCents:null,confirmation:form.settings.confirmation?.type==="message"?form.settings.confirmation.value:null,redirect:null};const minSeconds=spam.minSubmitSeconds??3;if(minSeconds>0&&options.renderedAtMs!==void 0&&options.renderedAtMs<minSeconds*1000)return{ok:!1,status:429,message:"That was submitted too quickly. Please try again."};const validated=validateSubmission(form,payload);if(!validated.ok)return{ok:!1,status:422,errors:validated.errors};const status=form.settings.payment&&(validated.amountCents??0)>0?"pending_payment":"complete",uuid=randomUUID(),now=sqlDateTime(new Date);await db.insertInto("form_submissions").values({uuid,form_id:form.id,site_id:form.siteId,data:JSON.stringify(validated.values),email:validated.email,name:validated.name,status,amount_cents:validated.amountCents,ip:options.ip??null,submitted_at:now,created_at:now,updated_at:now}).execute();const row=await db.selectFrom("form_submissions").where("uuid","=",uuid).select(["id"]).executeTakeFirst(),confirmation=form.settings.confirmation;return{ok:!0,submissionId:Number(row?.id??0),submissionUuid:uuid,status,amountCents:validated.amountCents,confirmation:confirmation?.type==="message"?confirmation.value:null,redirect:confirmation?.type==="redirect"?confirmation.value:null}}export async function completeSubmissionPayment(submissionUuid,paymentIntentId){const now=sqlDateTime(new Date),updated=await db.updateTable("form_submissions").set({status:"complete",payment_intent_id:paymentIntentId,updated_at:now}).where("uuid","=",submissionUuid).where("status","=","pending_payment").execute();return(typeof updated==="number"?updated:Number(updated?.changes??0))>0}export async function fetchSubmissions(formId,options={}){return(await db.selectFrom("form_submissions").where("form_id","=",formId).where("status","!=","spam").select(["id","uuid","data","email","name","status","amount_cents","submitted_at"]).orderBy("id","desc").limit(options.limit??100).offset(options.offset??0).execute()).map((row)=>({id:Number(row.id),uuid:row.uuid,values:(()=>{try{return row.data?JSON.parse(row.data):{}}catch{return{}}})(),email:row.email,name:row.name,status:row.status,amountCents:row.amount_cents==null?null:Number(row.amount_cents),submittedAt:row.submitted_at}))}function csvEscape(value){const text=value===void 0||value===null?"":String(value),defused=/^[=+\-@]/.test(text)?`'${text}`:text;return/[",\n\r]/.test(defused)?`"${defused.replace(/"/g,'""')}"`:defused}export async function exportSubmissionsCsv(form){const fields=form.fields.filter((field)=>field.type!=="section_break").sort((a,b)=>a.position-b.position),rows=await fetchSubmissions(form.id,{limit:1e5}),lines=[[...fields.map((field)=>field.label),"Email","Name","Status","Amount","Submitted at"].map(csvEscape).join(",")];for(const row of rows)lines.push([...fields.map((field)=>csvEscape(row.values[field.name])),csvEscape(row.email),csvEscape(row.name),csvEscape(row.status),csvEscape(row.amountCents==null?"":(row.amountCents/100).toFixed(2)),csvEscape(row.submittedAt)].join(","));return lines.join(`
|
|
2
|
+
`)}
|
package/dist/types.js
ADDED
|
File without changes
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{visibleFields}from"./conditions";const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/,PHONE_RE=/^[\d\s()+.\-ext]{7,32}$/i;function isBlank(value){return value===void 0||value===null||typeof value==="string"&&value.trim()===""}function checkField(field,value){const options=field.options??{};if(isBlank(value))return field.required?`${field.label} is required`:null;switch(field.type){case"text":case"textarea":{if(typeof value!=="string")return`${field.label} must be text`;const max=options.max??(field.type==="text"?500:1e4);if(value.length>max)return`${field.label} must be at most ${max} characters`;if(options.min&&value.length<options.min)return`${field.label} must be at least ${options.min} characters`;return null}case"email":return typeof value==="string"&&EMAIL_RE.test(value)&&value.length<=255?null:`${field.label} must be a valid email address`;case"phone":return typeof value==="string"&&PHONE_RE.test(value)?null:`${field.label} must be a valid phone number`;case"select":case"radio":{const choices=(options.choices??[]).map((choice)=>choice.value);return typeof value==="string"&&choices.includes(value)?null:`${field.label} must be one of the listed choices`}case"checkbox":return typeof value==="boolean"||value==="true"||value==="false"?null:`${field.label} must be a yes or no`;case"date":{if(typeof value!=="string"||Number.isNaN(Date.parse(value)))return`${field.label} must be a valid date`;return null}case"file":return typeof value==="string"&&value.length<=1024?null:`${field.label} upload is invalid`;case"currency":{const cents=Number(value);if(!Number.isInteger(cents)||cents<0)return`${field.label} must be a whole amount`;if(options.min!==void 0&¢s<options.min)return`${field.label} must be at least ${options.min}`;if(options.max!==void 0&¢s>options.max)return`${field.label} must be at most ${options.max}`;return null}case"section_break":return null;default:return`${field.label} has an unknown type`}}function coerce(field,value){if(field.type==="checkbox")return value===!0||value==="true";if(field.type==="currency")return Number(value);return value}function pickEmail(form,fields,values){const named=form.settings.emailField,field=named?fields.find((candidate)=>candidate.name===named):fields.find((candidate)=>candidate.type==="email"),value=field?values[field.name]:void 0;return typeof value==="string"&&value?value.toLowerCase():null}function pickName(form,fields,values){const named=form.settings.nameField,field=named?fields.find((candidate)=>candidate.name===named):fields.find((candidate)=>candidate.type==="text"&&/name/i.test(candidate.name)),value=field?values[field.name]:void 0;return typeof value==="string"&&value?value:null}export function computeAmountCents(form,fields,values){const payment=form.settings.payment;if(!payment)return null;switch(payment.mode){case"fixed":return payment.amountCents??0;case"field_sum":return fields.filter((field)=>field.type==="currency").reduce((sum,field)=>sum+(Number(values[field.name])||0),0);case"user_amount":{const field=payment.amountField?fields.find((candidate)=>candidate.name===payment.amountField):fields.find((candidate)=>candidate.type==="currency"),cents=field?Number(values[field.name])||0:0,floor=payment.minAmountCents??0;return Math.max(cents,floor)}default:return null}}export function validateSubmission(form,payload){const fields=visibleFields(form.fields,payload),errors={},values={};for(const field of fields){if(field.type==="section_break")continue;const raw=payload[field.name],problem=checkField(field,raw);if(problem){errors[field.name]=problem;continue}if(!isBlank(raw))values[field.name]=coerce(field,raw)}if(Object.keys(errors).length>0)return{ok:!1,errors};return{ok:!0,values,email:pickEmail(form,fields,values),name:pickName(form,fields,values),amountCents:computeAmountCents(form,fields,values)}}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/forms",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.71.
|
|
4
|
+
"version": "0.71.10",
|
|
5
5
|
"description": "The Stacks form-builder functionality.",
|
|
6
6
|
"author": "Chris Breuer",
|
|
7
7
|
"contributors": [
|
|
@@ -56,11 +56,11 @@
|
|
|
56
56
|
"prepublishOnly": "bun run build"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@stacksjs/config": "0.71.
|
|
60
|
-
"@stacksjs/database": "0.71.
|
|
61
|
-
"@stacksjs/notifications": "0.71.
|
|
62
|
-
"@stacksjs/storage": "0.71.
|
|
63
|
-
"@stacksjs/validation": "0.71.
|
|
59
|
+
"@stacksjs/config": "0.71.10",
|
|
60
|
+
"@stacksjs/database": "0.71.10",
|
|
61
|
+
"@stacksjs/notifications": "0.71.10",
|
|
62
|
+
"@stacksjs/storage": "0.71.10",
|
|
63
|
+
"@stacksjs/validation": "0.71.10",
|
|
64
64
|
"better-dx": "^0.2.23"
|
|
65
65
|
},
|
|
66
66
|
"sideEffects": false
|