feat: add survey backend endpoint

This commit is contained in:
moonrailgun 2024-04-28 14:55:29 +08:00
parent 9143cc468c
commit 27642625ac
11 changed files with 344 additions and 17 deletions

View File

@ -377,6 +377,9 @@ importers:
'@trpc/server':
specifier: ^10.45.0
version: 10.45.0
accept-language-parser:
specifier: ^1.5.0
version: 1.5.0
axios:
specifier: ^1.5.0
version: 1.5.0
@ -501,6 +504,9 @@ importers:
'@faker-js/faker':
specifier: ^8.4.0
version: 8.4.0
'@types/accept-language-parser':
specifier: ^1.5.6
version: 1.5.6
'@types/bcryptjs':
specifier: ^2.4.3
version: 2.4.3
@ -9967,6 +9973,10 @@ packages:
d3-voronoi: 1.1.2
dev: false
/@types/accept-language-parser@1.5.6:
resolution: {integrity: sha512-lhSQUsAhAtbKjYgaw3f0c4EQKNQHFXhX87+OXUIqDHMkycvHGaqGskSRtnzysIUiqHPqNJ4BqI5SE++drsxx6A==}
dev: true
/@types/acorn@4.0.6:
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
dependencies:
@ -10754,6 +10764,10 @@ packages:
event-target-shim: 5.0.1
dev: true
/accept-language-parser@1.5.0:
resolution: {integrity: sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==}
dev: false
/accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}

View File

@ -25,6 +25,7 @@
"@prisma/client": "5.4.2",
"@tianji/shared": "workspace:^",
"@trpc/server": "^10.45.0",
"accept-language-parser": "^1.5.0",
"axios": "^1.5.0",
"badge-maker": "^3.3.1",
"bcryptjs": "^2.4.3",
@ -68,6 +69,7 @@
},
"devDependencies": {
"@faker-js/faker": "^8.4.0",
"@types/accept-language-parser": "^1.5.6",
"@types/bcryptjs": "^2.4.3",
"@types/compression": "^1.7.2",
"@types/cors": "^2.8.15",

View File

@ -10,10 +10,12 @@ export const MonitorStatusPageListSchema = z.array(
);
export const SurveyPayloadSchema = z.object({
items: z.object({
label: z.string(),
name: z.string(),
type: z.enum(['text', 'select', 'email']),
options: z.array(z.string()).optional(),
}),
items: z.array(
z.object({
label: z.string(),
name: z.string(),
type: z.enum(['text', 'select', 'email']),
options: z.array(z.string()).optional(),
})
),
});

View File

@ -0,0 +1,276 @@
import { z } from 'zod';
import {
OpenApiMetaInfo,
publicProcedure,
router,
workspaceOwnerProcedure,
workspaceProcedure,
} from '../trpc';
import { OPENAPI_TAG } from '../../utils/const';
import { prisma } from '../../model/_client';
import { SurveyModelSchema, SurveyResultModelSchema } from '../../prisma/zod';
import { OpenApiMeta } from 'trpc-openapi';
import { hashUuid } from '../../utils/common';
import { getRequestInfo } from '../../utils/detect';
import { SurveyPayloadSchema } from '../../prisma/zod/schemas';
import { buildCursorResponseSchema } from '../../utils/schema';
import { fetchDataByCursor } from '../../utils/prisma';
export const telemetryRouter = router({
all: workspaceProcedure
.meta(
buildSurveyOpenapi({
method: 'GET',
path: '/all',
})
)
.output(z.array(SurveyModelSchema))
.query(async ({ input }) => {
const { workspaceId } = input;
const res = await prisma.survey.findMany({
where: {
workspaceId,
},
orderBy: {
updatedAt: 'desc',
},
});
return res;
}),
get: publicProcedure
.meta(
buildSurveyOpenapi({
method: 'GET',
path: '/{surveyId}',
})
)
.input(
z.object({
workspaceId: z.string(),
surveyId: z.string(),
})
)
.output(SurveyModelSchema.nullable())
.query(async ({ input }) => {
const { workspaceId, surveyId } = input;
const res = await prisma.survey.findUnique({
where: {
workspaceId,
id: surveyId,
},
});
return res;
}),
count: workspaceProcedure
.meta(
buildSurveyOpenapi({
method: 'GET',
path: '/{surveyId}/count',
})
)
.input(
z.object({
surveyId: z.string(),
})
)
.output(z.number())
.query(async ({ input }) => {
const { surveyId } = input;
const count = await prisma.surveyResult.count({
where: {
surveyId: surveyId,
},
});
return count;
}),
submit: publicProcedure
.meta(
buildSurveyOpenapi({
method: 'POST',
path: '/{surveyId}/submit',
})
)
.input(
z.object({
workspaceId: z.string(),
surveyId: z.string(),
payload: z.record(z.string(), z.any()),
})
)
.output(z.any())
.mutation(async ({ input, ctx }) => {
const { req } = ctx;
const { workspaceId, surveyId, payload } = input;
const {
userAgent,
browser,
os,
language,
ip,
country,
subdivision1,
subdivision2,
city,
longitude,
latitude,
accuracyRadius,
} = await getRequestInfo(req);
const sessionId = hashUuid(workspaceId, surveyId, ip, userAgent!);
await prisma.surveyResult.create({
data: {
surveyId,
sessionId,
payload,
browser,
os,
language,
ip,
country,
subdivision1,
subdivision2,
city,
longitude,
latitude,
accuracyRadius,
},
});
}),
create: workspaceOwnerProcedure
.meta(
buildSurveyOpenapi({
method: 'POST',
path: '/create',
})
)
.input(
z.object({
name: z.string(),
payload: SurveyPayloadSchema,
})
)
.output(SurveyModelSchema)
.mutation(async ({ input }) => {
const { workspaceId, name, payload } = input;
const res = await prisma.survey.create({
data: {
workspaceId,
name,
payload,
},
});
return res;
}),
update: workspaceOwnerProcedure
.meta(
buildSurveyOpenapi({
method: 'PATCH',
path: '/{surveyId}/update',
})
)
.input(
z.object({
surveyId: z.string(),
name: z.string().optional(),
payload: SurveyPayloadSchema.optional(),
})
)
.output(SurveyModelSchema)
.mutation(async ({ input }) => {
const { workspaceId, surveyId, name, payload } = input;
const res = await prisma.survey.update({
where: {
workspaceId,
id: surveyId,
},
data: {
name,
payload,
},
});
return res;
}),
delete: workspaceOwnerProcedure
.meta(
buildSurveyOpenapi({
method: 'DELETE',
path: '/{surveyId}/delete',
})
)
.input(
z.object({
surveyId: z.string(),
})
)
.output(SurveyModelSchema)
.mutation(async ({ input }) => {
const { workspaceId, surveyId } = input;
const res = await prisma.survey.delete({
where: {
id: surveyId,
workspaceId,
},
});
return res;
}),
resultList: workspaceProcedure
.meta(
buildSurveyOpenapi({
method: 'GET',
path: '/{surveyId}/result/list',
})
)
.input(
z.object({
surveyId: z.string(),
limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(),
})
)
.output(buildCursorResponseSchema(SurveyResultModelSchema))
.query(async ({ input }) => {
const limit = input.limit;
const { cursor, surveyId } = input;
const { items, nextCursor } = await fetchDataByCursor(
prisma.surveyResult,
{
where: {
surveyId,
},
limit,
cursor,
}
);
return {
items,
nextCursor,
};
}),
});
function buildSurveyOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {
return {
openapi: {
tags: [OPENAPI_TAG.SURVEY],
protect: true,
...meta,
path: `/workspace/{workspaceId}/survey/${meta.path}`,
},
};
}

View File

@ -427,7 +427,7 @@ function buildTelemetryOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {
tags: [OPENAPI_TAG.TELEMETRY],
protect: true,
...meta,
path: `/workspace/{workspaceId}${meta.path}`,
path: `/workspace/{workspaceId}/telemetry${meta.path}`,
},
};
}

View File

@ -11,7 +11,7 @@ export function createContext({ req }: { req: IncomingMessage }) {
const authorization = req.headers['authorization'] ?? '';
const token = authorization.replace('Bearer ', '');
return { token };
return { token, req };
}
type Context = inferAsyncReturnType<typeof createContext>;

View File

@ -110,4 +110,5 @@ export enum OPENAPI_TAG {
AUDIT_LOG = 'AuditLog',
BILLING = 'Billing',
TELEMETRY = 'Telemetry',
SURVEY = 'Survey',
}

View File

@ -12,8 +12,10 @@ import {
} from './const';
import maxmind, { Reader, CityResponse } from 'maxmind';
import { libraryPath } from './lib';
import { IncomingMessage } from 'http';
import { parse as pareseAcceptLanguage } from 'accept-language-parser';
export async function getRequestInfo(req: Request) {
export async function getRequestInfo(req: IncomingMessage) {
const userAgent = req.headers['user-agent'];
const ip = getIpAddress(req);
const location = await getLocation(ip);
@ -28,11 +30,14 @@ export async function getRequestInfo(req: Request) {
} = location ?? {};
const browser = browserName(userAgent ?? '');
const os = detectOS(userAgent ?? '');
const language: string | undefined =
pareseAcceptLanguage(req.headers['accept-language'])[0]?.code ?? undefined;
return {
userAgent,
browser,
os,
language,
ip,
country,
subdivision1,
@ -57,7 +62,7 @@ export async function getClientInfo(
};
}
export function getIpAddress(req: Request): string {
export function getIpAddress(req: IncomingMessage): string {
// Custom header
if (
process.env.CLIENT_IP_HEADER &&

View File

@ -116,13 +116,16 @@ export async function parseTelemetryFilters(
}
function normalizeFilters(filters: Record<string, any> = {}) {
return Object.keys(filters).reduce((obj, key) => {
const value = filters[key];
return Object.keys(filters).reduce(
(obj, key) => {
const value = filters[key];
obj[key] = value?.value ?? value;
obj[key] = value?.value ?? value;
return obj;
}, {} as Record<string, any>);
return obj;
},
{} as Record<string, any>
);
}
export function getWebsiteFilterQuery(
@ -217,11 +220,24 @@ type ExtractFindManyReturnType<T> = T extends (
? R
: never;
/**
* @example
* const { items, nextCursor } = await fetchDataByCursor(
* prisma.workspaceAuditLog,
* {
* where: {
* workspaceId,
* },
* limit,
* cursor,
* }
* );
*/
export async function fetchDataByCursor<
Model extends {
findMany: (args?: any) => Prisma.PrismaPromise<any>;
},
CursorType
CursorType,
>(
fetchModel: Model,
options: {

View File

@ -0,0 +1,11 @@
import { z } from 'zod';
export function buildCursorResponseSchema(
itemSchema: z.ZodType,
cursorSchema = z.string()
) {
return z.object({
items: z.array(itemSchema),
nextCursor: cursorSchema.optional(),
});
}

File diff suppressed because one or more lines are too long