Skip to content

Commit cce63b1

Browse files
Zeff01claude
andcommitted
feat: Add in-app survey system foundation with JSONB approach
Database Changes: - Create survey_questions table with JSONB options for flexibility - Create survey_responses table with JSONB answers - Add is_external flag to surveys table - Make survey_url optional for in-app surveys - Add helper functions for statistics and response summaries - Implement RLS policies for questions and responses Actions: - Create question CRUD operations (create, update, delete, reorder) - Create response operations (submit, fetch, statistics) - Add validation for external vs in-app surveys UI Updates: - Add "External Survey" toggle in SurveyForm - Conditionally show survey_url field only for external surveys - Update survey list to distinguish between external and in-app surveys - Show "View External Survey" link only for external surveys This implements Option 2 (JSONB approach) with only 2 new tables: - survey_questions: Store question text, type, and JSONB options - survey_responses: Store user responses with JSONB answers Next: Survey builder UI, response form, and analytics dashboard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 073b123 commit cce63b1

5 files changed

Lines changed: 770 additions & 28 deletions

File tree

‎apps/codebility/app/home/settings/surveys/_components/SurveyForm.tsx‎

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,24 @@ import SurveyImageUpload from "./SurveyImageUpload";
1717
const surveySchema = z.object({
1818
title: z.string().min(1, "Title is required").max(255, "Title too long"),
1919
description: z.string().min(1, "Description is required"),
20-
survey_url: z.string().url("Must be a valid URL"),
20+
is_external: z.boolean().default(false),
21+
survey_url: z.string().optional(),
2122
type: z.enum(["general", "feedback", "satisfaction", "research", "onboarding"]),
2223
image_url: z.string().optional(),
2324
target_audience: z.enum(["all", "codev", "intern", "hr", "admin"]),
2425
priority: z.number().min(1).max(100),
2526
is_active: z.boolean(),
2627
start_date: z.string(),
2728
end_date: z.string().optional(),
29+
}).refine((data) => {
30+
// If external survey, survey_url is required and must be valid
31+
if (data.is_external) {
32+
return data.survey_url && z.string().url().safeParse(data.survey_url).success;
33+
}
34+
return true;
35+
}, {
36+
message: "Survey URL is required for external surveys",
37+
path: ["survey_url"],
2838
});
2939

3040
type SurveyFormData = z.infer<typeof surveySchema>;
@@ -33,7 +43,8 @@ interface Survey {
3343
id: string;
3444
title: string;
3545
description: string;
36-
survey_url: string;
46+
is_external: boolean;
47+
survey_url?: string;
3748
type: "general" | "feedback" | "satisfaction" | "research" | "onboarding";
3849
image_url?: string;
3950
target_audience: "all" | "codev" | "intern" | "hr" | "admin";
@@ -64,6 +75,7 @@ export default function SurveyForm({ survey, onSuccess }: SurveyFormProps) {
6475
defaultValues: {
6576
title: survey?.title || "",
6677
description: survey?.description || "",
78+
is_external: survey?.is_external ?? false,
6779
survey_url: survey?.survey_url || "",
6880
type: survey?.type || "general",
6981
image_url: survey?.image_url || "",
@@ -76,6 +88,7 @@ export default function SurveyForm({ survey, onSuccess }: SurveyFormProps) {
7688
});
7789

7890
const isActive = watch("is_active");
91+
const isExternal = watch("is_external");
7992

8093
const onSubmit = async (data: SurveyFormData) => {
8194
setLoading(true);
@@ -85,7 +98,8 @@ export default function SurveyForm({ survey, onSuccess }: SurveyFormProps) {
8598
const surveyData = {
8699
title: data.title,
87100
description: data.description,
88-
survey_url: data.survey_url,
101+
is_external: data.is_external,
102+
survey_url: data.is_external ? data.survey_url : null,
89103
type: data.type,
90104
image_url: data.image_url || null,
91105
target_audience: data.target_audience,
@@ -130,6 +144,26 @@ export default function SurveyForm({ survey, onSuccess }: SurveyFormProps) {
130144

131145
return (
132146
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
147+
{/* Survey Type Toggle */}
148+
<div className="flex items-center space-x-2 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700">
149+
<Switch
150+
id="is_external"
151+
checked={isExternal}
152+
onCheckedChange={(checked) => setValue("is_external", checked)}
153+
className="data-[state=checked]:bg-customViolet-100 dark:[&>span]:bg-foreground [&>span]:bg-muted-foreground"
154+
/>
155+
<div className="flex-1">
156+
<Label htmlFor="is_external" className="text-foreground dark:text-gray-300 font-medium">
157+
External Survey
158+
</Label>
159+
<p className="text-sm text-gray-500 dark:text-gray-400">
160+
{isExternal
161+
? "Link to external survey (Google Forms, Typeform, etc.)"
162+
: "Build survey with questions directly in the app"}
163+
</p>
164+
</div>
165+
</div>
166+
133167
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
134168
<div className="space-y-2">
135169
<Label htmlFor="title" className="text-foreground dark:text-gray-300">Title *</Label>
@@ -145,19 +179,21 @@ export default function SurveyForm({ survey, onSuccess }: SurveyFormProps) {
145179
)}
146180
</div>
147181

148-
<div className="space-y-2">
149-
<Label htmlFor="survey_url" className="text-foreground dark:text-gray-300">Survey URL *</Label>
150-
<Input
151-
id="survey_url"
152-
variant="lightgray"
153-
{...register("survey_url")}
154-
placeholder="https://forms.google.com/..."
155-
className="rounded"
156-
/>
157-
{errors.survey_url && (
158-
<p className="text-sm text-red-600 dark:text-red-400">{errors.survey_url.message}</p>
159-
)}
160-
</div>
182+
{isExternal && (
183+
<div className="space-y-2">
184+
<Label htmlFor="survey_url" className="text-foreground dark:text-gray-300">Survey URL *</Label>
185+
<Input
186+
id="survey_url"
187+
variant="lightgray"
188+
{...register("survey_url")}
189+
placeholder="https://forms.google.com/..."
190+
className="rounded"
191+
/>
192+
{errors.survey_url && (
193+
<p className="text-sm text-red-600 dark:text-red-400">{errors.survey_url.message}</p>
194+
)}
195+
</div>
196+
)}
161197
</div>
162198

163199
<div className="space-y-2">

‎apps/codebility/app/home/settings/surveys/page.tsx‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ interface Survey {
1313
id: string;
1414
title: string;
1515
description: string;
16-
survey_url: string;
16+
is_external: boolean;
17+
survey_url?: string;
1718
type: "general" | "feedback" | "satisfaction" | "research" | "onboarding";
1819
image_url?: string;
1920
target_audience: "all" | "codev" | "intern" | "hr" | "admin";
@@ -253,17 +254,26 @@ export default function SurveysPage() {
253254
<p className="text-gray-600 dark:text-gray-300 mb-3">
254255
{survey.description}
255256
</p>
256-
<div className="flex items-center gap-2 mb-3">
257-
<a
258-
href={survey.survey_url}
259-
target="_blank"
260-
rel="noopener noreferrer"
261-
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
262-
>
263-
<ExternalLink className="h-3 w-3" />
264-
View Survey
265-
</a>
266-
</div>
257+
{survey.is_external && survey.survey_url && (
258+
<div className="flex items-center gap-2 mb-3">
259+
<a
260+
href={survey.survey_url}
261+
target="_blank"
262+
rel="noopener noreferrer"
263+
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
264+
>
265+
<ExternalLink className="h-3 w-3" />
266+
View External Survey
267+
</a>
268+
</div>
269+
)}
270+
{!survey.is_external && (
271+
<div className="flex items-center gap-2 mb-3">
272+
<span className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
273+
📋 In-App Survey
274+
</span>
275+
</div>
276+
)}
267277
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
268278
<div>Created: {formatDate(survey.start_date)}</div>
269279
{survey.end_date && (
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
"use server";
2+
3+
import { createClientServerComponent } from "@/utils/supabase/server";
4+
import { z } from "zod";
5+
6+
// Schema for question settings
7+
const questionSettingsSchema = z.object({
8+
required: z.boolean().default(false),
9+
placeholder: z.string().optional(),
10+
min_rating: z.number().optional(),
11+
max_rating: z.number().optional(),
12+
allow_other: z.boolean().optional(),
13+
});
14+
15+
// Schema for creating/updating a question
16+
const questionSchema = z.object({
17+
question_text: z.string().min(1, "Question text is required"),
18+
description: z.string().optional(),
19+
question_type: z.enum([
20+
"text",
21+
"textarea",
22+
"multiple_choice",
23+
"checkbox",
24+
"rating",
25+
"date",
26+
"email",
27+
"number"
28+
]),
29+
options: z.array(z.string()).default([]),
30+
settings: questionSettingsSchema.default({ required: false }),
31+
order_index: z.number().default(0),
32+
});
33+
34+
export async function createQuestion(surveyId: string, formData: z.infer<typeof questionSchema>) {
35+
try {
36+
const validatedData = questionSchema.parse(formData);
37+
const supabase = await createClientServerComponent();
38+
39+
// Verify user is admin
40+
const { data: user, error: userError } = await supabase.auth.getUser();
41+
if (userError || !user.user) {
42+
return { error: "Authentication required" };
43+
}
44+
45+
const { data: userRole, error: roleError } = await supabase
46+
.from("codev")
47+
.select("role_id")
48+
.eq("id", user.user.id)
49+
.single();
50+
51+
if (roleError || userRole?.role_id !== 1) {
52+
return { error: "Admin access required" };
53+
}
54+
55+
const { data, error } = await supabase
56+
.from("survey_questions")
57+
.insert({
58+
survey_id: surveyId,
59+
question_text: validatedData.question_text,
60+
description: validatedData.description,
61+
question_type: validatedData.question_type,
62+
options: validatedData.options,
63+
settings: validatedData.settings,
64+
order_index: validatedData.order_index,
65+
})
66+
.select()
67+
.single();
68+
69+
if (error) {
70+
return { error: error.message };
71+
}
72+
73+
return { data, success: true };
74+
} catch (error) {
75+
if (error instanceof z.ZodError) {
76+
return { error: error.errors[0].message };
77+
}
78+
return { error: "Failed to create question" };
79+
}
80+
}
81+
82+
export async function updateQuestion(
83+
questionId: string,
84+
formData: z.infer<typeof questionSchema>
85+
) {
86+
try {
87+
const validatedData = questionSchema.parse(formData);
88+
const supabase = await createClientServerComponent();
89+
90+
// Verify user is admin
91+
const { data: user, error: userError } = await supabase.auth.getUser();
92+
if (userError || !user.user) {
93+
return { error: "Authentication required" };
94+
}
95+
96+
const { data: userRole, error: roleError } = await supabase
97+
.from("codev")
98+
.select("role_id")
99+
.eq("id", user.user.id)
100+
.single();
101+
102+
if (roleError || userRole?.role_id !== 1) {
103+
return { error: "Admin access required" };
104+
}
105+
106+
const { data, error } = await supabase
107+
.from("survey_questions")
108+
.update({
109+
question_text: validatedData.question_text,
110+
description: validatedData.description,
111+
question_type: validatedData.question_type,
112+
options: validatedData.options,
113+
settings: validatedData.settings,
114+
order_index: validatedData.order_index,
115+
updated_at: new Date().toISOString(),
116+
})
117+
.eq("id", questionId)
118+
.select()
119+
.single();
120+
121+
if (error) {
122+
return { error: error.message };
123+
}
124+
125+
return { data, success: true };
126+
} catch (error) {
127+
if (error instanceof z.ZodError) {
128+
return { error: error.errors[0].message };
129+
}
130+
return { error: "Failed to update question" };
131+
}
132+
}
133+
134+
export async function deleteQuestion(questionId: string) {
135+
try {
136+
const supabase = await createClientServerComponent();
137+
138+
// Verify user is admin
139+
const { data: user, error: userError } = await supabase.auth.getUser();
140+
if (userError || !user.user) {
141+
return { error: "Authentication required" };
142+
}
143+
144+
const { data: userRole, error: roleError } = await supabase
145+
.from("codev")
146+
.select("role_id")
147+
.eq("id", user.user.id)
148+
.single();
149+
150+
if (roleError || userRole?.role_id !== 1) {
151+
return { error: "Admin access required" };
152+
}
153+
154+
const { error } = await supabase
155+
.from("survey_questions")
156+
.delete()
157+
.eq("id", questionId);
158+
159+
if (error) {
160+
return { error: error.message };
161+
}
162+
163+
return { success: true };
164+
} catch (error) {
165+
return { error: "Failed to delete question" };
166+
}
167+
}
168+
169+
export async function getSurveyQuestions(surveyId: string) {
170+
try {
171+
const supabase = await createClientServerComponent();
172+
173+
const { data, error } = await supabase
174+
.from("survey_questions")
175+
.select("*")
176+
.eq("survey_id", surveyId)
177+
.order("order_index", { ascending: true });
178+
179+
if (error) {
180+
return { error: error.message };
181+
}
182+
183+
return { data: data || [], success: true };
184+
} catch (error) {
185+
return { error: "Failed to fetch questions" };
186+
}
187+
}
188+
189+
export async function reorderQuestions(updates: Array<{ id: string; order_index: number }>) {
190+
try {
191+
const supabase = await createClientServerComponent();
192+
193+
// Verify user is admin
194+
const { data: user, error: userError } = await supabase.auth.getUser();
195+
if (userError || !user.user) {
196+
return { error: "Authentication required" };
197+
}
198+
199+
const { data: userRole, error: roleError } = await supabase
200+
.from("codev")
201+
.select("role_id")
202+
.eq("id", user.user.id)
203+
.single();
204+
205+
if (roleError || userRole?.role_id !== 1) {
206+
return { error: "Admin access required" };
207+
}
208+
209+
// Update all questions
210+
const updatePromises = updates.map(({ id, order_index }) =>
211+
supabase
212+
.from("survey_questions")
213+
.update({ order_index })
214+
.eq("id", id)
215+
);
216+
217+
await Promise.all(updatePromises);
218+
219+
return { success: true };
220+
} catch (error) {
221+
return { error: "Failed to reorder questions" };
222+
}
223+
}

0 commit comments

Comments
 (0)