Added foreignkey

Used checkboxes adnd not radio
main
Tom Peltonen 2026-10-24 10:57:57 +11:00
parent be2cfc8bfd
commit 56602612c8
12 changed files with 111 additions and 92 deletions

View File

@ -55,6 +55,7 @@ const (
Guid UserIdType = "guid" Guid UserIdType = "guid"
Phone UserIdType = "phone" Phone UserIdType = "phone"
Username UserIdType = "username" Username UserIdType = "username"
ForeignKey UserIdType = "foreignkey"
) )
// AccessCode defines model for AccessCode. // AccessCode defines model for AccessCode.
@ -382,7 +383,7 @@ type SubmitFormRequest struct {
// Tracking Client supplied tracking details. IP and user agent are captured server-side. // Tracking Client supplied tracking details. IP and user agent are captured server-side.
Tracking *ClientTracking `json:"tracking,omitempty"` Tracking *ClientTracking `json:"tracking,omitempty"`
// UserId Respondent identifier captured at the start (email, username, phone or GUID) // UserId Respondent identifier captured at the start (email, username, phone, foreignkey or GUID)
UserId string `json:"userId"` UserId string `json:"userId"`
UserIdType UserIdType `json:"userIdType"` UserIdType UserIdType `json:"userIdType"`
} }

View File

@ -449,7 +449,7 @@ func (s *Server) SubmitForm(ctx context.Context, req SubmitFormRequestObject) (S
userIdType := UserIdType(strings.ToLower(strings.TrimSpace(string(req.Body.UserIdType)))) userIdType := UserIdType(strings.ToLower(strings.TrimSpace(string(req.Body.UserIdType))))
if !validUserIdType(userIdType) { if !validUserIdType(userIdType) {
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON("userIdType must be one of email, username, phone or guid", "validation_error")}, nil return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON("userIdType must be one of email, username, phone, foreign key or guid", "validation_error")}, nil
} }
userId := strings.TrimSpace(req.Body.UserId) userId := strings.TrimSpace(req.Body.UserId)
if userId == "" { if userId == "" {
@ -1045,7 +1045,7 @@ func toFloat(v interface{}) (float64, bool) {
func validUserIdType(t UserIdType) bool { func validUserIdType(t UserIdType) bool {
switch t { switch t {
case Email, Username, Phone, Guid: case Email, Username, Phone, ForeignKey, Guid:
return true return true
default: default:
return false return false

View File

@ -183,7 +183,7 @@ func renderSubmitterDetails(doc *fpdf.Fpdf, form *database.Form) {
doc.SetXY(pdfLeftMargin, y) doc.SetXY(pdfLeftMargin, y)
doc.CellFormat(26, 6, "Identifier type:", "", 0, "L", false, 0, "") doc.CellFormat(26, 6, "Identifier type:", "", 0, "L", false, 0, "")
x := pdfLeftMargin + 28 x := pdfLeftMargin + 28
for _, label := range []string{"Email", "Username", "Phone", "GUID"} { for _, label := range []string{"Email", "Username", "Phone", "ForeignKey", "GUID"} {
drawCheckbox(doc, x, y+1) drawCheckbox(doc, x, y+1)
doc.SetXY(x+5, y) doc.SetXY(x+5, y)
doc.CellFormat(0, 6, label, "", 0, "L", false, 0, "") doc.CellFormat(0, 6, label, "", 0, "L", false, 0, "")

View File

@ -5,9 +5,13 @@ info:
description: | description: |
API for **Kererū forms**, a simple survey/form capture system. API for **Kererū forms**, a simple survey/form capture system.
Forms are versioned and can be inactivated. A form contains an ordered set of Forms are versioned and can be inactivated.
A form contains an ordered set of
questions whose answers are one of: true/false, numeric, a drop-down selection, questions whose answers are one of: true/false, numeric, a drop-down selection,
or free-form text. Responses are identified by a user identifier captured at the date or free-form text.
Responses are identified by a user identifier captured at the
start of the form. A form may optionally require a single-use access code. start of the form. A form may optionally require a single-use access code.
contact: contact:
name: Kererū Forms name: Kererū Forms
@ -806,7 +810,7 @@ components:
$ref: '#/components/schemas/QuestionInput' $ref: '#/components/schemas/QuestionInput'
UserIdType: UserIdType:
type: string type: string
enum: [email, username, phone, guid] enum: [email, username, phone, foreignkey guid]
Answer: Answer:
type: object type: object
required: [questionId, value] required: [questionId, value]
@ -822,7 +826,7 @@ components:
properties: properties:
userId: userId:
type: string type: string
description: Respondent identifier captured at the start (email, username, phone or GUID) description: Respondent identifier captured at the start (email, username, phone, foreignkey or GUID)
userIdType: userIdType:
$ref: '#/components/schemas/UserIdType' $ref: '#/components/schemas/UserIdType'
accessCode: accessCode:

View File

@ -82,8 +82,8 @@ the question along with the answwer.
## Database Schema ## Database Schema
The schema is to allow for multiple forms and capture the user input The schema is to allow for multiple forms and capture the user input
using an user identifier such as email, username, international phine number or GUID. using an user identifier such as email, username, international phone number,
The identifier is captured at the start. foreign key or GUID. The identifier is captured at the start.
Forms can be versioned and include a logo and company name plus contact details that is Forms can be versioned and include a logo and company name plus contact details that is
linked to the form. Existing forms can be edited which will generate a new version number linked to the form. Existing forms can be edited which will generate a new version number

View File

@ -90,7 +90,7 @@ All timestamps are stored as RFC 3339 UTC strings. IDs are UUIDs (TEXT).
Form definitions: non-empty title, unique keys (`[A-Za-z][A-Za-z0-9_-]*`), supported question types, select questions require unique non-empty option values, numeric bounds ordered. Form definitions: non-empty title, unique keys (`[A-Za-z][A-Za-z0-9_-]*`), supported question types, select questions require unique non-empty option values, numeric bounds ordered.
Submissions: identifier type must be `email|username|phone|guid`; email identifiers are parsed; answers must reference questions of that form; duplicates rejected; required questions enforced; type checks for boolean/number/select/text; numeric bounds; select values must exist; text length capped and single-line questions reject newlines. Submissions: identifier type must be `email|username|phone|foreignkey|guid`; email identifiers are parsed; answers must reference questions of that form; duplicates rejected; required questions enforced; type checks for boolean/number/select/text; numeric bounds; select values must exist; text length capped and single-line questions reject newlines.
Access codes are consumed in the same SQL transaction as the submission insert. The conditional `UPDATE ... WHERE used = 0` makes reuse safe under concurrency; failure rolls back the submission. Access codes are consumed in the same SQL transaction as the submission insert. The conditional `UPDATE ... WHERE used = 0` makes reuse safe under concurrency; failure rolls back the submission.

View File

@ -33,15 +33,15 @@ export function QuestionField({ question, value, onChange }: QuestionFieldProps)
if (question.type === 'boolean') { if (question.type === 'boolean') {
control = ( control = (
<div id={id} className="flex gap-6 pt-1" role="radiogroup" aria-label={question.label}> <div id={id} className="flex gap-6 pt-1" role="checkboxgroup" aria-label={question.label}>
{[ {[
{ label: 'True', checked: value === true }, { label: 'True', checked: value === true },
{ label: 'False', checked: value === false }, { label: 'False', checked: value === false },
].map((option) => ( ].map((option) => (
<label key={option.label} className="flex items-center gap-2 text-sm"> <label key={option.label} className="flex items-center gap-2 text-sm">
<input <input
type="radio" type="checkbox"
name={`radio-${question.id}`} name={`checkbox-${question.id}`}
checked={option.checked} checked={option.checked}
onChange={() => onChange(option.label === 'True')} onChange={() => onChange(option.label === 'True')}
className="h-4 w-4 accent-brand-600" className="h-4 w-4 accent-brand-600"

View File

@ -1,6 +1,6 @@
export type QuestionType = 'boolean' | 'date' | 'number' | 'select' | 'text' export type QuestionType = 'boolean' | 'date' | 'number' | 'select' | 'text'
export type UserIdType = 'email' | 'username' | 'phone' | 'guid' export type UserIdType = 'email' | 'username' | 'phone' | 'foreignkey' | 'guid'
export type SignatureType = 'drawn' | 'image' export type SignatureType = 'drawn' | 'image'

View File

@ -30,6 +30,7 @@ const userIdTypes: Array<{ value: UserIdType; label: string }> = [
{ value: 'email', label: 'Email address' }, { value: 'email', label: 'Email address' },
{ value: 'username', label: 'Username' }, { value: 'username', label: 'Username' },
{ value: 'phone', label: 'Phone number' }, { value: 'phone', label: 'Phone number' },
{ value: 'foreignkey', label: 'Provided key' },
{ value: 'guid', label: 'GUID' }, { value: 'guid', label: 'GUID' },
] ]
@ -65,6 +66,15 @@ export function FillFormPage() {
const [downloadingPdf, setDownloadingPdf] = useState(false) const [downloadingPdf, setDownloadingPdf] = useState(false)
const [pdfError, setPdfError] = useState<string | null>(null) const [pdfError, setPdfError] = useState<string | null>(null)
// Map option values to preferred placeholder texts
const placeholders = {
email: "Enter your email (e.g., name@example.com)",
phone: "Enter your phone number (e.g., +61403555012)",
foreignkey: "Use your supplied key",
username: "Enter your handle (e.g., @username)",
guid: "Enter your identifier"
};
useEffect(() => { useEffect(() => {
if (authLoading) return if (authLoading) return
if (submissionLocked) { if (submissionLocked) {
@ -390,7 +400,7 @@ export function FillFormPage() {
<legend className="label">How would you like to respond?</legend> <legend className="label">How would you like to respond?</legend>
<label className="flex items-start gap-2 text-sm"> <label className="flex items-start gap-2 text-sm">
<input <input
type="radio" type="checkbox"
name="completionMode" name="completionMode"
checked={completionMode === 'online'} checked={completionMode === 'online'}
onChange={() => setCompletionMode('online')} onChange={() => setCompletionMode('online')}
@ -405,7 +415,7 @@ export function FillFormPage() {
</label> </label>
<label className="flex items-start gap-2 text-sm"> <label className="flex items-start gap-2 text-sm">
<input <input
type="radio" type="checkbox"
name="completionMode" name="completionMode"
checked={completionMode === 'offline'} checked={completionMode === 'offline'}
onChange={() => setCompletionMode('offline')} onChange={() => setCompletionMode('offline')}
@ -466,7 +476,7 @@ export function FillFormPage() {
className="input" className="input"
value={userId} value={userId}
onChange={(event) => setUserId(event.target.value)} onChange={(event) => setUserId(event.target.value)}
placeholder={userIdType === 'email' ? 'you@example.com' : 'Enter your identifier'} placeholder={placeholders[userIdType] || 'Enter your identifier'} //placeholder={userIdType === 'email' ? 'you@example.com' : 'Enter your identifier'}
required required
/> />
</div> </div>

View File

@ -42,12 +42,15 @@ export function HomePage() {
<div className="space-y-8"> <div className="space-y-8">
<section className="card bg-gradient-to-br from-brand-600 to-brand-800 text-white dark:from-brand-800 dark:to-brand-950"> <section className="card bg-gradient-to-br from-brand-600 to-brand-800 text-white dark:from-brand-800 dark:to-brand-950">
<p className="text-sm font-semibold uppercase tracking-widest text-brand-100">Kererū Forms</p> <p className="text-sm font-semibold uppercase tracking-widest text-brand-100">Kererū Forms</p>
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">Simple, versioned surveys</h1> <h1 className="mt-2 text-3xl font-bold sm:text-4xl">Simple, versioned forms</h1>
<p className="mt-2 max-w-2xl text-brand-50"> <p className="mt-2 max-w-2xl text-brand-50">
Pick a form below to share your feedback. Forms are versioned, so your previous answers are Pick a form below to complete. Forms can also be thought of as surveys.
never shown again.
</p> </p>
{(user?.canAdmin || user?.canView) && ( {(user?.canAdmin || user?.canView) && (
<div>
<p className="mt-2 max-w-2xl text-brand-50">
Use the buttons below to manage forms or view submitted forms.
</p>
<div className="mt-5 flex flex-wrap gap-3"> <div className="mt-5 flex flex-wrap gap-3">
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50"> <Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
Manage forms Manage forms
@ -61,6 +64,7 @@ export function HomePage() {
</Link> </Link>
)} )}
</div> </div>
</div>
)} )}
</section> </section>

View File

@ -29,7 +29,7 @@ describe('StatusBadge', () => {
}) })
describe('QuestionField', () => { describe('QuestionField', () => {
it('renders boolean radios', () => { it('renders boolean checkboxes', () => {
const onChange = vi.fn() const onChange = vi.fn()
const { getByLabelText } = render( const { getByLabelText } = render(
<QuestionField <QuestionField