Skip to main content

Surveys Schema Deep Dive

Overview

The surveys module provides a flexible survey system with two table types: surveys for survey definitions and survey_responses for collected answers. Surveys can be either global (site-wide) or item-specific. Survey structure is stored as a JSON blob (surveyJson) using the JSONB column type, allowing dynamic question schemas without rigid database modeling.

Source file: template/lib/db/schema.ts


Table: surveys

Stores survey definitions with their question structure in a JSON column.

Columns

ColumnDB NameTypeNullableDefaultConstraints
ididtextNocrypto.randomUUID()Primary Key
slugslugtextNo-Unique
titletitletextNo--
descriptiondescriptiontextYes--
typetypetext (enum)No-global, item
itemIditem_idtextYes-Item slug (for item surveys)
statusstatustext (enum)No'draft'draft, published, closed
surveyJsonsurvey_jsonjsonbNo-Full survey structure
createdAtcreated_attimestamp (tz)Nonow()-
updatedAtupdated_attimestamp (tz)Nonow()-
publishedAtpublished_attimestamp (tz)Yes--
closedAtclosed_attimestamp (tz)Yes--
deletedAtdeleted_attimestamp (tz)Yes-Soft delete

Indexes

NameColumnsType
surveys_slug_idxslugB-tree
surveys_type_idxtypeB-tree
surveys_item_id_idxitemIdB-tree
surveys_status_idxstatusB-tree
surveys_created_at_idxcreatedAtB-tree

Survey Type Enum

ValueDescription
globalSite-wide survey visible to all users
itemSurvey attached to a specific item (referenced by itemId)

Survey Status Enum

ValueDescription
draftNot yet published, only visible to admins
publishedLive and accepting responses
closedNo longer accepting responses

Table: survey_responses

Stores individual user responses to surveys. Response data is stored as a JSONB blob.

Columns

ColumnDB NameTypeNullableDefaultConstraints
ididtextNocrypto.randomUUID()Primary Key
surveyIdsurvey_idtextNo-FK -> surveys.id (RESTRICT)
userIduser_idtextYes-FK -> users.id (SET NULL)
itemIditem_idtextYes-Item context slug
datadatajsonbNo-Response answers
completedAtcompleted_attimestamp (tz)No-When user finished
ipAddressip_addresstextYes-Submitter IP
userAgentuser_agenttextYes-Browser user agent
createdAtcreated_attimestamp (tz)Nonow()-
updatedAtupdated_attimestamp (tz)Nonow()-

Foreign Keys

ColumnReferencesOn Delete
surveyIdsurveys.idRESTRICT
userIdusers.idSET NULL
DELETE Behavior

The surveyId foreign key uses RESTRICT (not CASCADE), meaning a survey cannot be deleted while it has responses. This protects response data from accidental loss. Use soft-delete (deletedAt) on the survey instead.

The userId foreign key uses SET NULL, preserving anonymous response data even when a user account is deleted.

Indexes

NameColumnsType
survey_responses_survey_id_idxsurveyIdB-tree
survey_responses_user_id_idxuserIdB-tree
survey_responses_item_id_idxitemIdB-tree
survey_responses_completed_at_idxcompletedAtB-tree

TypeScript Types

export type Survey = typeof surveys.$inferSelect;

export type SurveyItem = Survey & {
responseCount?: number;
isCompletedByUser?: boolean;
};

export type NewSurvey = typeof surveys.$inferInsert;
export type SurveyResponse = typeof surveyResponses.$inferSelect;
export type NewSurveyResponse = typeof surveyResponses.$inferInsert;

Relations Diagram


Survey Lifecycle


The surveyJson Column

The surveyJson JSONB column stores the complete survey definition. This is a flexible schema that can represent various question types:

// Example surveyJson structure
{
"pages": [
{
"name": "page1",
"elements": [
{
"type": "rating",
"name": "satisfaction",
"title": "How satisfied are you?",
"rateMin": 1,
"rateMax": 5
},
{
"type": "text",
"name": "feedback",
"title": "Any additional feedback?"
},
{
"type": "radiogroup",
"name": "recommend",
"title": "Would you recommend this?",
"choices": ["Yes", "No", "Maybe"]
}
]
}
]
}

Query Examples

Create a survey

import { db } from '@/lib/db/drizzle';
import { surveys } from '@/lib/db/schema';

await db.insert(surveys).values({
slug: 'user-satisfaction-2025',
title: 'User Satisfaction Survey 2025',
description: 'Help us improve our platform',
type: 'global',
status: 'draft',
surveyJson: {
pages: [{
name: 'page1',
elements: [
{ type: 'rating', name: 'overall', title: 'Overall satisfaction' }
]
}]
},
});

Publish a survey

await db
.update(surveys)
.set({
status: 'published',
publishedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(surveys.id, surveyId));

Submit a response

import { surveyResponses } from '@/lib/db/schema';

await db.insert(surveyResponses).values({
surveyId,
userId,
itemId: 'specific-item-slug', // Optional, for item-type surveys
data: {
overall: 4,
feedback: 'Great platform, minor UI issues',
recommend: 'Yes',
},
completedAt: new Date(),
ipAddress: request.headers.get('x-forwarded-for'),
userAgent: request.headers.get('user-agent'),
});

Get surveys with response counts

import { sql } from 'drizzle-orm';

const surveysWithCounts = await db
.select({
id: surveys.id,
title: surveys.title,
status: surveys.status,
responseCount: sql<number>`(
SELECT count(*) FROM survey_responses
WHERE survey_responses.survey_id = surveys.id
)`,
})
.from(surveys)
.where(isNull(surveys.deletedAt))
.orderBy(desc(surveys.createdAt));

Check if user completed a survey

const completed = await db
.select({ id: surveyResponses.id })
.from(surveyResponses)
.where(
and(
eq(surveyResponses.surveyId, surveyId),
eq(surveyResponses.userId, userId)
)
)
.limit(1);

const hasCompleted = completed.length > 0;

Get published surveys for an item

const itemSurveys = await db
.select()
.from(surveys)
.where(
and(
eq(surveys.type, 'item'),
eq(surveys.itemId, 'my-item-slug'),
eq(surveys.status, 'published'),
isNull(surveys.deletedAt)
)
);

Design Notes

  • JSONB for flexibility. Using surveyJson and data as JSONB columns allows the survey system to support any question type without schema migrations. The trade-off is less strict type safety at the database level.
  • RESTRICT on delete. Surveys with responses cannot be hard-deleted. Use the deletedAt soft-delete column instead.
  • Anonymous responses supported. The userId on survey_responses is nullable and uses SET NULL on delete, allowing both authenticated and anonymous survey submissions.
  • Item context. The itemId field on both tables enables item-specific surveys (e.g., "Rate this tool") while keeping the schema generic enough for global surveys.