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

@ -146,5 +146,5 @@ See [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) and [`deploy/k8s.yaml`](deploy/k8
Management endpoints require a bearer JWT. In local-password mode change the default `admin` password and set a stable `localTokenSecret`;
in OIDC mode enforce MFA and role assignment in the identity provider. Use HTTPS in production, keep `corsOrigins` narrow,
and enable `requireSubmissionAuth` if forms must not be publicly accessible. Access codes protect
and enable `requireSubmissionAuth` if forms must not be publicly accessible. Access codes protect
individual form submissions, not administration.

View File

@ -51,10 +51,11 @@ const (
// Defines values for UserIdType.
const (
Email UserIdType = "email"
Guid UserIdType = "guid"
Phone UserIdType = "phone"
Username UserIdType = "username"
Email UserIdType = "email"
Guid UserIdType = "guid"
Phone UserIdType = "phone"
Username UserIdType = "username"
ForeignKey UserIdType = "foreignkey"
)
// 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 *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"`
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))))
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)
if userId == "" {
@ -1045,7 +1045,7 @@ func toFloat(v interface{}) (float64, bool) {
func validUserIdType(t UserIdType) bool {
switch t {
case Email, Username, Phone, Guid:
case Email, Username, Phone, ForeignKey, Guid:
return true
default:
return false

View File

@ -183,7 +183,7 @@ func renderSubmitterDetails(doc *fpdf.Fpdf, form *database.Form) {
doc.SetXY(pdfLeftMargin, y)
doc.CellFormat(26, 6, "Identifier type:", "", 0, "L", false, 0, "")
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)
doc.SetXY(x+5, y)
doc.CellFormat(0, 6, label, "", 0, "L", false, 0, "")

View File

@ -5,9 +5,13 @@ info:
description: |
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,
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.
contact:
name: Kererū Forms
@ -806,7 +810,7 @@ components:
$ref: '#/components/schemas/QuestionInput'
UserIdType:
type: string
enum: [email, username, phone, guid]
enum: [email, username, phone, foreignkey guid]
Answer:
type: object
required: [questionId, value]
@ -822,7 +826,7 @@ components:
properties:
userId:
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:
$ref: '#/components/schemas/UserIdType'
accessCode:

View File

@ -82,8 +82,8 @@ the question along with the answwer.
## Database Schema
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.
The identifier is captured at the start.
using an user identifier such as email, username, international phone number,
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
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.
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.

View File

@ -33,15 +33,15 @@ export function QuestionField({ question, value, onChange }: QuestionFieldProps)
if (question.type === 'boolean') {
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: 'False', checked: value === false },
].map((option) => (
<label key={option.label} className="flex items-center gap-2 text-sm">
<input
type="radio"
name={`radio-${question.id}`}
type="checkbox"
name={`checkbox-${question.id}`}
checked={option.checked}
onChange={() => onChange(option.label === 'True')}
className="h-4 w-4 accent-brand-600"

View File

@ -1,6 +1,6 @@
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'

View File

@ -30,6 +30,7 @@ const userIdTypes: Array<{ value: UserIdType; label: string }> = [
{ value: 'email', label: 'Email address' },
{ value: 'username', label: 'Username' },
{ value: 'phone', label: 'Phone number' },
{ value: 'foreignkey', label: 'Provided key' },
{ value: 'guid', label: 'GUID' },
]
@ -64,6 +65,15 @@ export function FillFormPage() {
const [submission, setSubmission] = useState<Submission | null>(null)
const [downloadingPdf, setDownloadingPdf] = useState(false)
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(() => {
if (authLoading) return
@ -390,7 +400,7 @@ export function FillFormPage() {
<legend className="label">How would you like to respond?</legend>
<label className="flex items-start gap-2 text-sm">
<input
type="radio"
type="checkbox"
name="completionMode"
checked={completionMode === 'online'}
onChange={() => setCompletionMode('online')}
@ -405,7 +415,7 @@ export function FillFormPage() {
</label>
<label className="flex items-start gap-2 text-sm">
<input
type="radio"
type="checkbox"
name="completionMode"
checked={completionMode === 'offline'}
onChange={() => setCompletionMode('offline')}
@ -466,7 +476,7 @@ export function FillFormPage() {
className="input"
value={userId}
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
/>
</div>

View File

@ -42,24 +42,28 @@ export function HomePage() {
<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">
<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">
Pick a form below to share your feedback. Forms are versioned, so your previous answers are
never shown again.
Pick a form below to complete. Forms can also be thought of as surveys.
</p>
{(user?.canAdmin || user?.canView) && (
<div className="mt-5 flex flex-wrap gap-3">
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
Manage forms
</Link>
{user?.canAdmin && (
<Link
to="/manage/new"
className="btn border border-white/40 text-white hover:bg-white/10"
>
Create a form
<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">
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
Manage forms
</Link>
)}
{user?.canAdmin && (
<Link
to="/manage/new"
className="btn border border-white/40 text-white hover:bg-white/10"
>
Create a form
</Link>
)}
</div>
</div>
)}
</section>
@ -67,65 +71,65 @@ export function HomePage() {
{submissionLocked ? (
<SignInPrompt message="Sign in with your organisation account to view and complete forms." />
) : (
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold">Available forms</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{forms.length} active {forms.length === 1 ? 'form' : 'forms'}
</span>
</div>
{loading && <p className="text-slate-500 dark:text-slate-400">Loading forms…</p>}
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
{error}
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold">Available forms</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{forms.length} active {forms.length === 1 ? 'form' : 'forms'}
</span>
</div>
)}
{!loading && !error && forms.length === 0 && (
<div className="card text-center">
<p className="text-lg font-semibold">No active forms yet</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Create one from the management section to get started.
</p>
</div>
)}
{loading && <p className="text-slate-500 dark:text-slate-400">Loading forms…</p>}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{forms.map((form) => (
<article key={form.id} className="card flex flex-col justify-between">
<div>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
{form.logoUrl && (
<img
src={form.logoUrl}
alt=""
className="h-8 w-auto"
referrerPolicy="no-referrer"
/>
)}
<h3 className="text-lg font-semibold">{form.title}</h3>
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
{error}
</div>
)}
{!loading && !error && forms.length === 0 && (
<div className="card text-center">
<p className="text-lg font-semibold">No active forms yet</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Create one from the management section to get started.
</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{forms.map((form) => (
<article key={form.id} className="card flex flex-col justify-between">
<div>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
{form.logoUrl && (
<img
src={form.logoUrl}
alt=""
className="h-8 w-auto"
referrerPolicy="no-referrer"
/>
)}
<h3 className="text-lg font-semibold">{form.title}</h3>
</div>
<StatusBadge active={form.isActive} />
</div>
<StatusBadge active={form.isActive} />
</div>
{form.description && (
<p className="mt-2 line-clamp-3 text-sm text-slate-500 dark:text-slate-400">
{form.description}
{form.description && (
<p className="mt-2 line-clamp-3 text-sm text-slate-500 dark:text-slate-400">
{form.description}
</p>
)}
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400">
Version {form.version} · {form.questionCount} questions
</p>
)}
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400">
Version {form.version} · {form.questionCount} questions
</p>
</div>
<Link to={`/forms/${form.id}`} className="btn-primary mt-5 w-full">
Open form
</Link>
</article>
))}
</div>
</section>
</div>
<Link to={`/forms/${form.id}`} className="btn-primary mt-5 w-full">
Open form
</Link>
</article>
))}
</div>
</section>
)}
</div>
)

View File

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