@agentic-survey/core 0.1.0
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/LICENSE +21 -0
- package/README.md +14 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.js +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/mappers.d.ts +5 -0
- package/dist/mappers.js +41 -0
- package/dist/questions.d.ts +24 -0
- package/dist/questions.js +97 -0
- package/dist/responses.d.ts +23 -0
- package/dist/responses.js +57 -0
- package/dist/result.d.ts +22 -0
- package/dist/result.js +11 -0
- package/dist/results.d.ts +12 -0
- package/dist/results.js +173 -0
- package/dist/sharelink.d.ts +14 -0
- package/dist/sharelink.js +5 -0
- package/dist/surveys.d.ts +26 -0
- package/dist/surveys.js +105 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 netmonty
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @agentic-survey/core
|
|
2
|
+
|
|
3
|
+
Pure TypeScript service layer for [agentic-survey-mcp](https://github.com/netmonty/agentic-survey-mcp). All the survey logic (create, publish, collect, aggregate) over an injected Supabase client. No MCP, no HTTP, no framework types. Functions return a typed `Result` and never throw.
|
|
4
|
+
|
|
5
|
+
Most people want [`@agentic-survey/mcp-server`](https://www.npmjs.com/package/@agentic-survey/mcp-server) instead. Use this directly only if you're building your own integration on top of the survey model.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createDb, createSurvey, getResults } from '@agentic-survey/core';
|
|
9
|
+
|
|
10
|
+
const db = createDb(supabaseUrl, secretKey);
|
|
11
|
+
const survey = await createSurvey(db, { title: 'NPS' });
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
[MIT](./LICENSE). Docs and issues on [GitHub](https://github.com/netmonty/agentic-survey-mcp).
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
/**
|
|
3
|
+
* The injected database handle. `core` never reads global config — callers
|
|
4
|
+
* build this from supplied credentials and pass it to every function.
|
|
5
|
+
*/
|
|
6
|
+
export type Db = SupabaseClient;
|
|
7
|
+
/**
|
|
8
|
+
* Build a Supabase client from a project URL + SECRET key (`sb_secret_…`).
|
|
9
|
+
* The secret key bypasses RLS; it lives only on the user's machine.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createDb(url: string, secretKey: string): Db;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
/**
|
|
3
|
+
* Build a Supabase client from a project URL + SECRET key (`sb_secret_…`).
|
|
4
|
+
* The secret key bypasses RLS; it lives only on the user's machine.
|
|
5
|
+
*/
|
|
6
|
+
export function createDb(url, secretKey) {
|
|
7
|
+
return createClient(url, secretKey, {
|
|
8
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
9
|
+
});
|
|
10
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createDb, type Db } from './client.js';
|
|
2
|
+
export { ok, err, isErr, fromThrown, type Result, type Ok, type Err, } from './result.js';
|
|
3
|
+
export { buildShareUrl, type LinkConfig } from './sharelink.js';
|
|
4
|
+
export { createSurvey, updateSurvey, publishSurvey, closeSurvey, listSurveys, getSurvey, } from './surveys.js';
|
|
5
|
+
export { addQuestion, updateQuestion, removeQuestion, reorderQuestions, } from './questions.js';
|
|
6
|
+
export { listResponses, type ResponseWithAnswers } from './responses.js';
|
|
7
|
+
export { getResults } from './results.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createDb } from './client.js';
|
|
2
|
+
export { ok, err, isErr, fromThrown, } from './result.js';
|
|
3
|
+
export { buildShareUrl } from './sharelink.js';
|
|
4
|
+
export { createSurvey, updateSurvey, publishSurvey, closeSurvey, listSurveys, getSurvey, } from './surveys.js';
|
|
5
|
+
export { addQuestion, updateQuestion, removeQuestion, reorderQuestions, } from './questions.js';
|
|
6
|
+
export { listResponses } from './responses.js';
|
|
7
|
+
export { getResults } from './results.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Survey, Question, Response, Answer } from '@agentic-survey/schema';
|
|
2
|
+
export declare function toSurvey(row: any): Survey;
|
|
3
|
+
export declare function toQuestion(row: any): Question;
|
|
4
|
+
export declare function toResponse(row: any): Response;
|
|
5
|
+
export declare function toAnswer(row: any): Answer;
|
package/dist/mappers.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* DB rows are snake_case; domain types are camelCase. Map at the boundary. */
|
|
2
|
+
export function toSurvey(row) {
|
|
3
|
+
return {
|
|
4
|
+
id: row.id,
|
|
5
|
+
title: row.title,
|
|
6
|
+
description: row.description ?? null,
|
|
7
|
+
status: row.status,
|
|
8
|
+
config: (row.config ?? {}),
|
|
9
|
+
createdAt: row.created_at,
|
|
10
|
+
updatedAt: row.updated_at,
|
|
11
|
+
publishedAt: row.published_at ?? null,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function toQuestion(row) {
|
|
15
|
+
return {
|
|
16
|
+
id: row.id,
|
|
17
|
+
surveyId: row.survey_id,
|
|
18
|
+
position: row.position,
|
|
19
|
+
type: row.type,
|
|
20
|
+
prompt: row.prompt,
|
|
21
|
+
required: row.required,
|
|
22
|
+
config: (row.config ?? {}),
|
|
23
|
+
logic: row.logic ?? null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function toResponse(row) {
|
|
27
|
+
return {
|
|
28
|
+
id: row.id,
|
|
29
|
+
surveyId: row.survey_id,
|
|
30
|
+
submittedAt: row.submitted_at,
|
|
31
|
+
respondentMeta: (row.respondent_meta ?? {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function toAnswer(row) {
|
|
35
|
+
return {
|
|
36
|
+
id: row.id,
|
|
37
|
+
responseId: row.response_id,
|
|
38
|
+
questionId: row.question_id,
|
|
39
|
+
value: row.value,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Question, QuestionType, QuestionConfig } from '@agentic-survey/schema';
|
|
2
|
+
import type { Db } from './client.js';
|
|
3
|
+
import { type Result } from './result.js';
|
|
4
|
+
export declare function addQuestion(db: Db, surveyId: string, input: {
|
|
5
|
+
type: QuestionType;
|
|
6
|
+
prompt: string;
|
|
7
|
+
required?: boolean;
|
|
8
|
+
config?: QuestionConfig;
|
|
9
|
+
position?: number;
|
|
10
|
+
}): Promise<Result<Question>>;
|
|
11
|
+
export declare function updateQuestion(db: Db, questionId: string, patch: {
|
|
12
|
+
type?: QuestionType;
|
|
13
|
+
prompt?: string;
|
|
14
|
+
required?: boolean;
|
|
15
|
+
config?: QuestionConfig;
|
|
16
|
+
position?: number;
|
|
17
|
+
}): Promise<Result<Question>>;
|
|
18
|
+
export declare function removeQuestion(db: Db, questionId: string): Promise<Result<void>>;
|
|
19
|
+
/**
|
|
20
|
+
* Reorder a survey's questions to match `orderedIds`. Positions become the
|
|
21
|
+
* index in the array. Done sequentially; there's no unique constraint on
|
|
22
|
+
* position, so intermediate states are fine.
|
|
23
|
+
*/
|
|
24
|
+
export declare function reorderQuestions(db: Db, surveyId: string, orderedIds: string[]): Promise<Result<void>>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ok, err, fromThrown } from './result.js';
|
|
2
|
+
import { toQuestion } from './mappers.js';
|
|
3
|
+
export async function addQuestion(db, surveyId, input) {
|
|
4
|
+
try {
|
|
5
|
+
let position = input.position;
|
|
6
|
+
if (position === undefined) {
|
|
7
|
+
// Append: one past the current max position for this survey.
|
|
8
|
+
const { data, error } = await db
|
|
9
|
+
.from('questions')
|
|
10
|
+
.select('position')
|
|
11
|
+
.eq('survey_id', surveyId)
|
|
12
|
+
.order('position', { ascending: false })
|
|
13
|
+
.limit(1);
|
|
14
|
+
if (error)
|
|
15
|
+
return err('add_question_failed', error.message);
|
|
16
|
+
position = data && data.length > 0 ? data[0].position + 1 : 0;
|
|
17
|
+
}
|
|
18
|
+
const { data, error } = await db
|
|
19
|
+
.from('questions')
|
|
20
|
+
.insert({
|
|
21
|
+
survey_id: surveyId,
|
|
22
|
+
type: input.type,
|
|
23
|
+
prompt: input.prompt,
|
|
24
|
+
required: input.required ?? false,
|
|
25
|
+
config: input.config ?? {},
|
|
26
|
+
position,
|
|
27
|
+
})
|
|
28
|
+
.select()
|
|
29
|
+
.single();
|
|
30
|
+
if (error)
|
|
31
|
+
return err('add_question_failed', error.message);
|
|
32
|
+
return ok(toQuestion(data));
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
return fromThrown('add_question_failed', e);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function updateQuestion(db, questionId, patch) {
|
|
39
|
+
try {
|
|
40
|
+
const row = {};
|
|
41
|
+
if (patch.type !== undefined)
|
|
42
|
+
row.type = patch.type;
|
|
43
|
+
if (patch.prompt !== undefined)
|
|
44
|
+
row.prompt = patch.prompt;
|
|
45
|
+
if (patch.required !== undefined)
|
|
46
|
+
row.required = patch.required;
|
|
47
|
+
if (patch.config !== undefined)
|
|
48
|
+
row.config = patch.config;
|
|
49
|
+
if (patch.position !== undefined)
|
|
50
|
+
row.position = patch.position;
|
|
51
|
+
const { data, error } = await db
|
|
52
|
+
.from('questions')
|
|
53
|
+
.update(row)
|
|
54
|
+
.eq('id', questionId)
|
|
55
|
+
.select()
|
|
56
|
+
.single();
|
|
57
|
+
if (error)
|
|
58
|
+
return err('update_question_failed', error.message);
|
|
59
|
+
return ok(toQuestion(data));
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
return fromThrown('update_question_failed', e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function removeQuestion(db, questionId) {
|
|
66
|
+
try {
|
|
67
|
+
const { error } = await db.from('questions').delete().eq('id', questionId);
|
|
68
|
+
if (error)
|
|
69
|
+
return err('remove_question_failed', error.message);
|
|
70
|
+
return ok(undefined);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
return fromThrown('remove_question_failed', e);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Reorder a survey's questions to match `orderedIds`. Positions become the
|
|
78
|
+
* index in the array. Done sequentially; there's no unique constraint on
|
|
79
|
+
* position, so intermediate states are fine.
|
|
80
|
+
*/
|
|
81
|
+
export async function reorderQuestions(db, surveyId, orderedIds) {
|
|
82
|
+
try {
|
|
83
|
+
for (let i = 0; i < orderedIds.length; i++) {
|
|
84
|
+
const { error } = await db
|
|
85
|
+
.from('questions')
|
|
86
|
+
.update({ position: i })
|
|
87
|
+
.eq('id', orderedIds[i])
|
|
88
|
+
.eq('survey_id', surveyId);
|
|
89
|
+
if (error)
|
|
90
|
+
return err('reorder_questions_failed', error.message);
|
|
91
|
+
}
|
|
92
|
+
return ok(undefined);
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
return fromThrown('reorder_questions_failed', e);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AnswerValue } from '@agentic-survey/schema';
|
|
2
|
+
import type { Db } from './client.js';
|
|
3
|
+
import { type Result } from './result.js';
|
|
4
|
+
export interface ResponseWithAnswers {
|
|
5
|
+
id: string;
|
|
6
|
+
submittedAt: string;
|
|
7
|
+
respondentMeta: Record<string, unknown>;
|
|
8
|
+
answers: {
|
|
9
|
+
questionId: string;
|
|
10
|
+
value: AnswerValue;
|
|
11
|
+
}[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Paginated raw responses (with their answers), keyset-ordered by
|
|
15
|
+
* (submitted_at, id) so the agent can page through everything stably.
|
|
16
|
+
*/
|
|
17
|
+
export declare function listResponses(db: Db, surveyId: string, opts?: {
|
|
18
|
+
limit?: number;
|
|
19
|
+
cursor?: string;
|
|
20
|
+
}): Promise<Result<{
|
|
21
|
+
responses: ResponseWithAnswers[];
|
|
22
|
+
nextCursor: string | null;
|
|
23
|
+
}>>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ok, err, fromThrown } from './result.js';
|
|
2
|
+
const DEFAULT_LIMIT = 50;
|
|
3
|
+
const MAX_LIMIT = 200;
|
|
4
|
+
function encodeCursor(c) {
|
|
5
|
+
return Buffer.from(JSON.stringify(c)).toString('base64url');
|
|
6
|
+
}
|
|
7
|
+
function decodeCursor(s) {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(Buffer.from(s, 'base64url').toString('utf8'));
|
|
10
|
+
if (typeof parsed?.submittedAt === 'string' && typeof parsed?.id === 'string')
|
|
11
|
+
return parsed;
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Paginated raw responses (with their answers), keyset-ordered by
|
|
20
|
+
* (submitted_at, id) so the agent can page through everything stably.
|
|
21
|
+
*/
|
|
22
|
+
export async function listResponses(db, surveyId, opts = {}) {
|
|
23
|
+
const limit = Math.min(Math.max(opts.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
|
|
24
|
+
try {
|
|
25
|
+
let q = db
|
|
26
|
+
.from('responses')
|
|
27
|
+
.select('id, submitted_at, respondent_meta, answers(question_id, value)')
|
|
28
|
+
.eq('survey_id', surveyId)
|
|
29
|
+
.order('submitted_at', { ascending: true })
|
|
30
|
+
.order('id', { ascending: true })
|
|
31
|
+
.limit(limit + 1);
|
|
32
|
+
if (opts.cursor) {
|
|
33
|
+
const c = decodeCursor(opts.cursor);
|
|
34
|
+
if (c) {
|
|
35
|
+
q = q.or(`submitted_at.gt."${c.submittedAt}",and(submitted_at.eq."${c.submittedAt}",id.gt.${c.id})`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const { data, error } = await q;
|
|
39
|
+
if (error)
|
|
40
|
+
return err('list_responses_failed', error.message);
|
|
41
|
+
const rows = data ?? [];
|
|
42
|
+
const hasMore = rows.length > limit;
|
|
43
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
44
|
+
const responses = page.map((r) => ({
|
|
45
|
+
id: r.id,
|
|
46
|
+
submittedAt: r.submitted_at,
|
|
47
|
+
respondentMeta: r.respondent_meta ?? {},
|
|
48
|
+
answers: (r.answers ?? []).map((a) => ({ questionId: a.question_id, value: a.value })),
|
|
49
|
+
}));
|
|
50
|
+
const last = page[page.length - 1];
|
|
51
|
+
const nextCursor = hasMore && last ? encodeCursor({ submittedAt: last.submitted_at, id: last.id }) : null;
|
|
52
|
+
return ok({ responses, nextCursor });
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return fromThrown('list_responses_failed', e);
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/result.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result type — every service function returns this rather than throwing
|
|
3
|
+
* across the boundary. The `error` shape matches the brief's typed-error
|
|
4
|
+
* contract ({ error: { code, message } }).
|
|
5
|
+
*/
|
|
6
|
+
export type Ok<T> = {
|
|
7
|
+
ok: true;
|
|
8
|
+
data: T;
|
|
9
|
+
};
|
|
10
|
+
export type Err = {
|
|
11
|
+
ok: false;
|
|
12
|
+
error: {
|
|
13
|
+
code: string;
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export type Result<T> = Ok<T> | Err;
|
|
18
|
+
export declare const ok: <T>(data: T) => Ok<T>;
|
|
19
|
+
export declare const err: (code: string, message: string) => Err;
|
|
20
|
+
export declare const isErr: <T>(r: Result<T>) => r is Err;
|
|
21
|
+
/** Wrap an unknown thrown value into a typed Err. */
|
|
22
|
+
export declare function fromThrown(code: string, e: unknown): Err;
|
package/dist/result.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const ok = (data) => ({ ok: true, data });
|
|
2
|
+
export const err = (code, message) => ({
|
|
3
|
+
ok: false,
|
|
4
|
+
error: { code, message },
|
|
5
|
+
});
|
|
6
|
+
export const isErr = (r) => !r.ok;
|
|
7
|
+
/** Wrap an unknown thrown value into a typed Err. */
|
|
8
|
+
export function fromThrown(code, e) {
|
|
9
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
10
|
+
return err(code, message);
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { GetResultsPayload } from '@agentic-survey/schema';
|
|
2
|
+
import type { Db } from './client.js';
|
|
3
|
+
import { type Result } from './result.js';
|
|
4
|
+
/**
|
|
5
|
+
* Aggregates (over ALL responses) + a page of raw responses. Cheap arithmetic
|
|
6
|
+
* is done here so the consumer agent doesn't burn tokens on it; text answers
|
|
7
|
+
* are handed back raw for the agent to theme.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getResults(db: Db, surveyId: string, opts?: {
|
|
10
|
+
responsesLimit?: number;
|
|
11
|
+
cursor?: string;
|
|
12
|
+
}): Promise<Result<GetResultsPayload>>;
|
package/dist/results.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { ok, err, isErr, fromThrown } from './result.js';
|
|
2
|
+
import { getSurvey } from './surveys.js';
|
|
3
|
+
import { listResponses } from './responses.js';
|
|
4
|
+
const pct = (count, total) => total ? Math.round((count / total) * 1000) / 10 : 0;
|
|
5
|
+
function choiceAggregate(q, values) {
|
|
6
|
+
const opts = (q.config.options ?? []);
|
|
7
|
+
const counts = new Map();
|
|
8
|
+
const labels = new Map();
|
|
9
|
+
for (const o of opts) {
|
|
10
|
+
counts.set(o.id, 0);
|
|
11
|
+
labels.set(o.id, o.label);
|
|
12
|
+
}
|
|
13
|
+
let totalAnswered = 0;
|
|
14
|
+
const bump = (sel) => {
|
|
15
|
+
counts.set(sel.optionId, (counts.get(sel.optionId) ?? 0) + 1);
|
|
16
|
+
if (!labels.has(sel.optionId))
|
|
17
|
+
labels.set(sel.optionId, sel.label);
|
|
18
|
+
};
|
|
19
|
+
for (const v of values) {
|
|
20
|
+
if (v.kind === 'single_choice') {
|
|
21
|
+
totalAnswered++;
|
|
22
|
+
bump(v.selection);
|
|
23
|
+
}
|
|
24
|
+
else if (v.kind === 'multi_choice') {
|
|
25
|
+
totalAnswered++;
|
|
26
|
+
for (const s of v.selections)
|
|
27
|
+
bump(s);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const options = [...counts.entries()].map(([optionId, count]) => ({
|
|
31
|
+
optionId,
|
|
32
|
+
label: labels.get(optionId) ?? optionId,
|
|
33
|
+
count,
|
|
34
|
+
pct: pct(count, totalAnswered),
|
|
35
|
+
}));
|
|
36
|
+
return { kind: 'choice', options, totalAnswered };
|
|
37
|
+
}
|
|
38
|
+
function yesNoAggregate(values) {
|
|
39
|
+
let yes = 0;
|
|
40
|
+
let no = 0;
|
|
41
|
+
let total = 0;
|
|
42
|
+
for (const v of values) {
|
|
43
|
+
if (v.kind === 'yes_no') {
|
|
44
|
+
total++;
|
|
45
|
+
if (v.value)
|
|
46
|
+
yes++;
|
|
47
|
+
else
|
|
48
|
+
no++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
kind: 'choice',
|
|
53
|
+
totalAnswered: total,
|
|
54
|
+
options: [
|
|
55
|
+
{ optionId: 'yes', label: 'Yes', count: yes, pct: pct(yes, total) },
|
|
56
|
+
{ optionId: 'no', label: 'No', count: no, pct: pct(no, total) },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function numericAggregate(values) {
|
|
61
|
+
const nums = [];
|
|
62
|
+
for (const v of values)
|
|
63
|
+
if (v.kind === 'rating' || v.kind === 'number')
|
|
64
|
+
nums.push(v.value);
|
|
65
|
+
const totalAnswered = nums.length;
|
|
66
|
+
if (!totalAnswered) {
|
|
67
|
+
return { kind: 'numeric', mean: null, median: null, min: null, max: null, distribution: [], totalAnswered: 0 };
|
|
68
|
+
}
|
|
69
|
+
const sorted = [...nums].sort((a, b) => a - b);
|
|
70
|
+
const sum = nums.reduce((a, b) => a + b, 0);
|
|
71
|
+
const mid = Math.floor(sorted.length / 2);
|
|
72
|
+
const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
73
|
+
const distMap = new Map();
|
|
74
|
+
for (const n of nums)
|
|
75
|
+
distMap.set(n, (distMap.get(n) ?? 0) + 1);
|
|
76
|
+
const distribution = [...distMap.entries()]
|
|
77
|
+
.sort((a, b) => a[0] - b[0])
|
|
78
|
+
.map(([bucket, count]) => ({ bucket: String(bucket), count }));
|
|
79
|
+
return {
|
|
80
|
+
kind: 'numeric',
|
|
81
|
+
mean: Math.round((sum / totalAnswered) * 100) / 100,
|
|
82
|
+
median,
|
|
83
|
+
min: sorted[0],
|
|
84
|
+
max: sorted[sorted.length - 1],
|
|
85
|
+
distribution,
|
|
86
|
+
totalAnswered,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function textAggregate(values) {
|
|
90
|
+
const responses = [];
|
|
91
|
+
for (const v of values)
|
|
92
|
+
if (v.kind === 'short_text' || v.kind === 'long_text')
|
|
93
|
+
responses.push(v.text);
|
|
94
|
+
return { kind: 'text', totalAnswered: responses.length, responses };
|
|
95
|
+
}
|
|
96
|
+
function aggregateQuestion(q, values) {
|
|
97
|
+
switch (q.type) {
|
|
98
|
+
case 'single_choice':
|
|
99
|
+
case 'multi_choice':
|
|
100
|
+
return choiceAggregate(q, values);
|
|
101
|
+
case 'yes_no':
|
|
102
|
+
return yesNoAggregate(values);
|
|
103
|
+
case 'rating':
|
|
104
|
+
case 'number':
|
|
105
|
+
return numericAggregate(values);
|
|
106
|
+
case 'short_text':
|
|
107
|
+
case 'long_text':
|
|
108
|
+
return textAggregate(values);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Aggregates (over ALL responses) + a page of raw responses. Cheap arithmetic
|
|
113
|
+
* is done here so the consumer agent doesn't burn tokens on it; text answers
|
|
114
|
+
* are handed back raw for the agent to theme.
|
|
115
|
+
*/
|
|
116
|
+
export async function getResults(db, surveyId, opts = {}) {
|
|
117
|
+
try {
|
|
118
|
+
const sv = await getSurvey(db, surveyId);
|
|
119
|
+
if (isErr(sv))
|
|
120
|
+
return sv;
|
|
121
|
+
const { survey, questions } = sv.data;
|
|
122
|
+
const { count, error: cErr } = await db
|
|
123
|
+
.from('responses')
|
|
124
|
+
.select('id', { count: 'exact', head: true })
|
|
125
|
+
.eq('survey_id', surveyId);
|
|
126
|
+
if (cErr)
|
|
127
|
+
return err('get_results_failed', cErr.message);
|
|
128
|
+
const responseCount = count ?? 0;
|
|
129
|
+
// All answers for the survey (joined via the response's survey_id).
|
|
130
|
+
const { data: ansRows, error: aErr } = await db
|
|
131
|
+
.from('answers')
|
|
132
|
+
.select('question_id, value, responses!inner(survey_id)')
|
|
133
|
+
.eq('responses.survey_id', surveyId);
|
|
134
|
+
if (aErr)
|
|
135
|
+
return err('get_results_failed', aErr.message);
|
|
136
|
+
const byQuestion = new Map();
|
|
137
|
+
for (const r of (ansRows ?? [])) {
|
|
138
|
+
const arr = byQuestion.get(r.question_id) ?? [];
|
|
139
|
+
arr.push(r.value);
|
|
140
|
+
byQuestion.set(r.question_id, arr);
|
|
141
|
+
}
|
|
142
|
+
const page = await listResponses(db, surveyId, {
|
|
143
|
+
limit: opts.responsesLimit,
|
|
144
|
+
cursor: opts.cursor,
|
|
145
|
+
});
|
|
146
|
+
if (isErr(page))
|
|
147
|
+
return page;
|
|
148
|
+
return ok({
|
|
149
|
+
survey: {
|
|
150
|
+
id: survey.id,
|
|
151
|
+
title: survey.title,
|
|
152
|
+
status: survey.status,
|
|
153
|
+
questionCount: questions.length,
|
|
154
|
+
responseCount,
|
|
155
|
+
},
|
|
156
|
+
questions: questions.map((q) => ({
|
|
157
|
+
id: q.id,
|
|
158
|
+
type: q.type,
|
|
159
|
+
prompt: q.prompt,
|
|
160
|
+
aggregate: aggregateQuestion(q, byQuestion.get(q.id) ?? []),
|
|
161
|
+
})),
|
|
162
|
+
responses: page.data.responses.map((r) => ({
|
|
163
|
+
id: r.id,
|
|
164
|
+
submittedAt: r.submittedAt,
|
|
165
|
+
answers: r.answers,
|
|
166
|
+
})),
|
|
167
|
+
pagination: { nextCursor: page.data.nextCursor },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
return fromThrown('get_results_failed', e);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure share-link construction. `core` stays free of endpoint/key config by
|
|
3
|
+
* taking it as an argument. The publishable key is client-safe (RLS-fenced)
|
|
4
|
+
* and travels with the link — see docs/trust-model.md.
|
|
5
|
+
*/
|
|
6
|
+
export interface LinkConfig {
|
|
7
|
+
/** Page-service base URL, e.g. https://pages.example.com */
|
|
8
|
+
pageEndpoint: string;
|
|
9
|
+
/** The user's Supabase project ref. */
|
|
10
|
+
projectRef: string;
|
|
11
|
+
/** The user's client-safe publishable key (sb_publishable_…). */
|
|
12
|
+
publishableKey: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildShareUrl(link: LinkConfig, surveyId: string): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Survey, SurveyStatus, SurveyConfig, Question } from '@agentic-survey/schema';
|
|
2
|
+
import type { Db } from './client.js';
|
|
3
|
+
import { type Result } from './result.js';
|
|
4
|
+
import { type LinkConfig } from './sharelink.js';
|
|
5
|
+
export declare function createSurvey(db: Db, input: {
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
config?: SurveyConfig;
|
|
9
|
+
}): Promise<Result<Survey>>;
|
|
10
|
+
export declare function updateSurvey(db: Db, id: string, patch: {
|
|
11
|
+
title?: string;
|
|
12
|
+
description?: string | null;
|
|
13
|
+
config?: SurveyConfig;
|
|
14
|
+
}): Promise<Result<Survey>>;
|
|
15
|
+
export declare function publishSurvey(db: Db, id: string, link?: LinkConfig): Promise<Result<{
|
|
16
|
+
survey: Survey;
|
|
17
|
+
shareUrl?: string;
|
|
18
|
+
}>>;
|
|
19
|
+
export declare function closeSurvey(db: Db, id: string): Promise<Result<Survey>>;
|
|
20
|
+
export declare function listSurveys(db: Db, opts?: {
|
|
21
|
+
status?: SurveyStatus;
|
|
22
|
+
}): Promise<Result<Survey[]>>;
|
|
23
|
+
export declare function getSurvey(db: Db, id: string): Promise<Result<{
|
|
24
|
+
survey: Survey;
|
|
25
|
+
questions: Question[];
|
|
26
|
+
}>>;
|
package/dist/surveys.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ok, err, fromThrown } from './result.js';
|
|
2
|
+
import { toSurvey, toQuestion } from './mappers.js';
|
|
3
|
+
import { buildShareUrl } from './sharelink.js';
|
|
4
|
+
export async function createSurvey(db, input) {
|
|
5
|
+
try {
|
|
6
|
+
const { data, error } = await db
|
|
7
|
+
.from('surveys')
|
|
8
|
+
.insert({
|
|
9
|
+
title: input.title,
|
|
10
|
+
description: input.description ?? null,
|
|
11
|
+
config: input.config ?? {},
|
|
12
|
+
})
|
|
13
|
+
.select()
|
|
14
|
+
.single();
|
|
15
|
+
if (error)
|
|
16
|
+
return err('create_survey_failed', error.message);
|
|
17
|
+
return ok(toSurvey(data));
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
return fromThrown('create_survey_failed', e);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function updateSurvey(db, id, patch) {
|
|
24
|
+
try {
|
|
25
|
+
const row = {};
|
|
26
|
+
if (patch.title !== undefined)
|
|
27
|
+
row.title = patch.title;
|
|
28
|
+
if (patch.description !== undefined)
|
|
29
|
+
row.description = patch.description;
|
|
30
|
+
if (patch.config !== undefined)
|
|
31
|
+
row.config = patch.config;
|
|
32
|
+
const { data, error } = await db.from('surveys').update(row).eq('id', id).select().single();
|
|
33
|
+
if (error)
|
|
34
|
+
return err('update_survey_failed', error.message);
|
|
35
|
+
return ok(toSurvey(data));
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
return fromThrown('update_survey_failed', e);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function publishSurvey(db, id, link) {
|
|
42
|
+
try {
|
|
43
|
+
const { data, error } = await db
|
|
44
|
+
.from('surveys')
|
|
45
|
+
.update({ status: 'published', published_at: new Date().toISOString() })
|
|
46
|
+
.eq('id', id)
|
|
47
|
+
.select()
|
|
48
|
+
.single();
|
|
49
|
+
if (error)
|
|
50
|
+
return err('publish_survey_failed', error.message);
|
|
51
|
+
const survey = toSurvey(data);
|
|
52
|
+
return ok({ survey, shareUrl: link ? buildShareUrl(link, survey.id) : undefined });
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return fromThrown('publish_survey_failed', e);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function closeSurvey(db, id) {
|
|
59
|
+
try {
|
|
60
|
+
const { data, error } = await db
|
|
61
|
+
.from('surveys')
|
|
62
|
+
.update({ status: 'closed' })
|
|
63
|
+
.eq('id', id)
|
|
64
|
+
.select()
|
|
65
|
+
.single();
|
|
66
|
+
if (error)
|
|
67
|
+
return err('close_survey_failed', error.message);
|
|
68
|
+
return ok(toSurvey(data));
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
return fromThrown('close_survey_failed', e);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function listSurveys(db, opts = {}) {
|
|
75
|
+
try {
|
|
76
|
+
let q = db.from('surveys').select().order('created_at', { ascending: false });
|
|
77
|
+
if (opts.status)
|
|
78
|
+
q = q.eq('status', opts.status);
|
|
79
|
+
const { data, error } = await q;
|
|
80
|
+
if (error)
|
|
81
|
+
return err('list_surveys_failed', error.message);
|
|
82
|
+
return ok((data ?? []).map(toSurvey));
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return fromThrown('list_surveys_failed', e);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function getSurvey(db, id) {
|
|
89
|
+
try {
|
|
90
|
+
const { data: sRow, error: sErr } = await db.from('surveys').select().eq('id', id).single();
|
|
91
|
+
if (sErr)
|
|
92
|
+
return err('get_survey_failed', sErr.message);
|
|
93
|
+
const { data: qRows, error: qErr } = await db
|
|
94
|
+
.from('questions')
|
|
95
|
+
.select()
|
|
96
|
+
.eq('survey_id', id)
|
|
97
|
+
.order('position', { ascending: true });
|
|
98
|
+
if (qErr)
|
|
99
|
+
return err('get_survey_failed', qErr.message);
|
|
100
|
+
return ok({ survey: toSurvey(sRow), questions: (qRows ?? []).map(toQuestion) });
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
return fromThrown('get_survey_failed', e);
|
|
104
|
+
}
|
|
105
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentic-survey/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pure TypeScript service layer for survey logic. No MCP, no HTTP, no framework types.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Monty Daley <monty@daley.org.nz>",
|
|
7
|
+
"homepage": "https://www.mcpsurveys.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/netmonty/agentic-survey-mcp.git",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/netmonty/agentic-survey-mcp/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"survey",
|
|
16
|
+
"supabase",
|
|
17
|
+
"mcp",
|
|
18
|
+
"forms",
|
|
19
|
+
"service-layer"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "dist/index.js",
|
|
29
|
+
"types": "dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -b",
|
|
41
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
42
|
+
"test": "node --import tsx --env-file-if-exists=../../.env --test src/**/*.test.ts",
|
|
43
|
+
"prepublishOnly": "tsc -b"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@agentic-survey/schema": "^0.1.0",
|
|
47
|
+
"@supabase/supabase-js": "^2.45.0"
|
|
48
|
+
}
|
|
49
|
+
}
|