Area restrita - Questionario

This commit is contained in:
2026-06-08 23:50:59 -03:00
parent f2e2400637
commit ef968f10ae
6972 changed files with 23454 additions and 2267883 deletions
+22
View File
@@ -0,0 +1,22 @@
import * as cookie from "cookie";
import { Session } from "@contracts/constants";
import { getSessionCookieOptions } from "./lib/cookies";
import { createRouter, authedQuery } from "./middleware";
export const authRouter = createRouter({
me: authedQuery.query((opts) => opts.ctx.user),
logout: authedQuery.mutation(async ({ ctx }) => {
const opts = getSessionCookieOptions(ctx.req.headers);
ctx.resHeaders.append(
"set-cookie",
cookie.serialize(Session.cookieName, "", {
httpOnly: opts.httpOnly,
path: opts.path,
sameSite: opts.sameSite?.toLowerCase() as "lax" | "none",
secure: opts.secure,
maxAge: 0,
}),
);
return { success: true };
}),
});
+36
View File
@@ -0,0 +1,36 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { HttpBindings } from "@hono/node-server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "./router";
import { createContext } from "./context";
import { env } from "./lib/env";
import { createOAuthCallbackHandler } from "./kimi/auth";
import { Paths } from "@contracts/constants";
const app = new Hono<{ Bindings: HttpBindings }>();
app.use(bodyLimit({ maxSize: 50 * 1024 * 1024 }));
app.get(Paths.oauthCallback, createOAuthCallbackHandler());
app.use("/api/trpc/*", async (c) => {
return fetchRequestHandler({
endpoint: "/api/trpc",
req: c.req.raw,
router: appRouter,
createContext,
});
});
app.all("/api/*", (c) => c.json({ error: "Not Found" }, 404));
export default app;
if (env.isProduction) {
const { serve } = await import("@hono/node-server");
const { serveStaticFiles } = await import("./lib/vite");
serveStaticFiles(app);
const port = parseInt(process.env.PORT || "3000");
serve({ fetch: app.fetch, port }, () => {
console.log(`Server running on http://localhost:${port}/`);
});
}
+21
View File
@@ -0,0 +1,21 @@
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import type { User } from "@db/schema";
import { authenticateRequest } from "./kimi/auth";
export type TrpcContext = {
req: Request;
resHeaders: Headers;
user?: User;
};
export async function createContext(
opts: FetchCreateContextFnOptions,
): Promise<TrpcContext> {
const ctx: TrpcContext = { req: opts.req, resHeaders: opts.resHeaders };
try {
ctx.user = await authenticateRequest(opts.req.headers);
} catch {
// Authentication is optional here
}
return ctx;
}
+211
View File
@@ -0,0 +1,211 @@
// ============================================================================
// FORM ROUTER - Area do Cliente Avanzato
// ============================================================================
// Routers para gestao de formularios e respostas
// ============================================================================
import { z } from "zod";
import { createRouter, publicQuery, authedQuery } from "./middleware";
import { getDb } from "./queries/connection";
import { clientForms, formSections, formQuestions, formAnswers, formSubmissions } from "../db/schema";
import { eq, desc, and } from "drizzle-orm";
// Gerar codigo aleatorio de 8 caracteres
function generateCode(): string {
return Math.random().toString(36).substring(2, 10).toUpperCase();
}
export const formRouter = createRouter({
// ==========================================================================
// ADMIN - Gerenciamento de Formularios
// ==========================================================================
// Listar todos os formularios
list: publicQuery.query(async () => {
const db = getDb();
const forms = await db.select().from(clientForms).orderBy(desc(clientForms.createdAt));
return forms;
}),
// Criar novo formulario
create: publicQuery
.input(
z.object({
title: z.string().min(1),
description: z.string().optional(),
clientName: z.string().min(1),
clientEmail: z.string().email().optional(),
})
)
.mutation(async ({ input }) => {
const db = getDb();
const code = generateCode();
await db.insert(clientForms).values({
title: input.title,
description: input.description || "",
clientName: input.clientName,
clientEmail: input.clientEmail || "",
status: "active",
accessCode: code,
createdBy: "Avanzato",
});
return { code, message: "Formulario criado com sucesso" };
}),
// Atualizar status (active/inactive/archived)
updateStatus: publicQuery
.input(
z.object({
accessCode: z.string(),
status: z.enum(["active", "inactive", "archived"]),
})
)
.mutation(async ({ input }) => {
const db = getDb();
await db
.update(clientForms)
.set({ status: input.status })
.where(eq(clientForms.accessCode, input.accessCode));
return { message: "Status atualizado" };
}),
// Excluir formulario
delete: publicQuery
.input(z.object({ accessCode: z.string() }))
.mutation(async ({ input }) => {
const db = getDb();
// Primeiro excluir respostas e perguntas
await db.delete(formAnswers).where(eq(formAnswers.formId, input.accessCode));
await db.delete(formQuestions).where(eq(formQuestions.formId, input.accessCode));
await db.delete(formSections).where(eq(formSections.formId, input.accessCode));
await db.delete(formSubmissions).where(eq(formSubmissions.formId, input.accessCode));
await db.delete(clientForms).where(eq(clientForms.accessCode, input.accessCode));
return { message: "Formulario excluido" };
}),
// Renovar codigo de acesso
regenerateCode: publicQuery
.input(z.object({ accessCode: z.string() }))
.mutation(async ({ input }) => {
const db = getDb();
const newCode = generateCode();
await db
.update(clientForms)
.set({ accessCode: newCode })
.where(eq(clientForms.accessCode, input.accessCode));
return { newCode };
}),
// ==========================================================================
// PUBLICO - Acesso ao Formulario via Codigo
// ==========================================================================
// Buscar formulario por codigo (publico)
getByCode: publicQuery
.input(z.object({ code: z.string() }))
.query(async ({ input }) => {
const db = getDb();
const [form] = await db
.select()
.from(clientForms)
.where(eq(clientForms.accessCode, input.code));
if (!form) return null;
if (form.status !== "active") return { ...form, _blocked: true };
// Buscar perguntas agrupadas por secao
const questions = await db
.select()
.from(formQuestions)
.where(eq(formQuestions.formId, input.code))
.orderBy(formQuestions.id);
const sections = await db
.select()
.from(formSections)
.where(eq(formSections.formId, input.code))
.orderBy(formSections.id);
return { ...form, questions, sections };
}),
// Submeter respostas do formulario
submit: publicQuery
.input(
z.object({
formId: z.string(),
code: z.string(),
answers: z.array(
z.object({
questionId: z.string(),
answer: z.string(),
})
),
submittedBy: z.string().optional(),
submittedByEmail: z.string().optional(),
})
)
.mutation(async ({ input }) => {
const db = getDb();
// Verificar se formulario esta ativo
const [form] = await db
.select()
.from(clientForms)
.where(eq(clientForms.accessCode, input.code));
if (!form || form.status !== "active") {
throw new Error("Formulario nao encontrado ou inativo");
}
// Inserir submissao
await db.insert(formSubmissions).values({
formId: input.formId,
submittedBy: input.submittedBy || "Anonimo",
submittedByEmail: input.submittedByEmail || "",
});
// Inserir respostas
for (const a of input.answers) {
await db.insert(formAnswers).values({
formId: input.formId,
questionId: a.questionId,
answer: a.answer,
});
}
return { message: "Respostas submetidas com sucesso" };
}),
// ==========================================================================
// ADMIN - Visualizar Respostas
// ==========================================================================
// Listar submissoes de um formulario
getSubmissions: publicQuery
.input(z.object({ accessCode: z.string() }))
.query(async ({ input }) => {
const db = getDb();
const submissions = await db
.select()
.from(formSubmissions)
.where(eq(formSubmissions.formId, input.accessCode))
.orderBy(desc(formSubmissions.createdAt));
return submissions;
}),
// Obter todas as respostas de uma submissao
getAnswers: publicQuery
.input(z.object({ formId: z.string() }))
.query(async ({ input }) => {
const db = getDb();
const answers = await db
.select()
.from(formAnswers)
.where(eq(formAnswers.formId, input.formId))
.orderBy(formAnswers.id);
return answers;
}),
});
+130
View File
@@ -0,0 +1,130 @@
import type { Context } from "hono";
import { setCookie } from "hono/cookie";
import * as jose from "jose";
import * as cookie from "cookie";
import { env } from "../lib/env";
import { getSessionCookieOptions } from "../lib/cookies";
import { Session } from "@contracts/constants";
import { Errors } from "@contracts/errors";
import { signSessionToken, verifySessionToken } from "./session";
import { users as kimiUsers } from "./platform";
import { findUserByUnionId, upsertUser } from "../queries/users";
import type { TokenResponse } from "./types";
async function exchangeAuthCode(
code: string,
redirectUri: string,
): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: env.appId,
redirect_uri: redirectUri,
client_secret: env.appSecret,
});
const resp = await fetch(`${env.kimiAuthUrl}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Token exchange failed (${resp.status}): ${text}`);
}
return resp.json() as Promise<TokenResponse>;
}
const jwks = jose.createRemoteJWKSet(
new URL(`${env.kimiAuthUrl}/api/.well-known/jwks.json`),
);
async function verifyAccessToken(
accessToken: string,
): Promise<{ userId: string; clientId: string }> {
const { payload } = await jose.jwtVerify(accessToken, jwks);
const userId = payload.user_id as string;
const clientId = payload.client_id as string;
if (!userId) {
throw new Error("user_id missing from access token");
}
return { userId, clientId };
}
export async function authenticateRequest(headers: Headers) {
const cookies = cookie.parse(headers.get("cookie") || "");
const token = cookies[Session.cookieName];
if (!token) {
console.warn("[auth] No session cookie found in request.");
throw Errors.forbidden("Invalid authentication token.");
}
const claim = await verifySessionToken(token);
if (!claim) {
throw Errors.forbidden("Invalid authentication token.");
}
const user = await findUserByUnionId(claim.unionId);
if (!user) {
throw Errors.forbidden("User not found. Please re-login.");
}
return user;
}
export function createOAuthCallbackHandler() {
return async (c: Context) => {
const code = c.req.query("code");
const state = c.req.query("state");
const error = c.req.query("error");
const errorDescription = c.req.query("error_description");
if (error) {
if (error === "access_denied") {
return c.redirect("/", 302);
}
return c.json(
{ error, error_description: errorDescription },
400,
);
}
if (!code || !state) {
return c.json({ error: "code and state are required" }, 400);
}
try {
const redirectUri = atob(state);
const tokenResp = await exchangeAuthCode(code, redirectUri);
const { userId } = await verifyAccessToken(tokenResp.access_token);
const userProfile = await kimiUsers.getProfile(tokenResp.access_token);
if (!userProfile) {
throw new Error("Failed to fetch user profile from Kimi Open");
}
await upsertUser({
unionId: userId,
name: userProfile.name,
avatar: userProfile.avatar_url,
lastSignInAt: new Date(),
});
const token = await signSessionToken({
unionId: userId,
clientId: env.appId,
});
const cookieOpts = getSessionCookieOptions(c.req.raw.headers);
setCookie(c, Session.cookieName, token, {
...cookieOpts,
maxAge: Session.maxAgeMs / 1000,
});
return c.redirect("/", 302);
} catch (error) {
console.error("[OAuth] Callback failed", error);
return c.json({ error: "OAuth callback failed" }, 500);
}
};
}
export { exchangeAuthCode, verifyAccessToken };
+30
View File
@@ -0,0 +1,30 @@
import { env } from "../lib/env";
import type { UserProfile } from "./types";
async function kimiRequest<T>(
path: string,
token: string,
init?: RequestInit,
): Promise<T | null> {
const resp = await fetch(`${env.kimiOpenUrl}${path}`, {
...init,
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
...init?.headers,
},
});
if (!resp.ok) {
const text = await resp.text();
console.warn(
`[kimi] Request to ${path} failed (${resp.status}): ${text}`,
);
return null;
}
return resp.json() as Promise<T>;
}
export const users = {
getProfile: (token: string) =>
kimiRequest<UserProfile>("/v1/users/me/profile", token),
};
+40
View File
@@ -0,0 +1,40 @@
import * as jose from "jose";
import { env } from "../lib/env";
import type { SessionPayload } from "./types";
const JWT_ALG = "HS256";
export async function signSessionToken(
payload: SessionPayload,
): Promise<string> {
const secret = new TextEncoder().encode(env.appSecret);
return new jose.SignJWT(payload)
.setProtectedHeader({ alg: JWT_ALG })
.setIssuedAt()
.setExpirationTime("1 year")
.sign(secret);
}
export async function verifySessionToken(
token: string,
): Promise<SessionPayload | null> {
if (!token) {
console.warn("[session] No token provided for verification.");
return null;
}
try {
const secret = new TextEncoder().encode(env.appSecret);
const { payload } = await jose.jwtVerify(token, secret, {
algorithms: [JWT_ALG],
});
const { unionId, clientId } = payload;
if (!unionId || !clientId) {
console.warn("[session] JWT payload missing required fields.");
return null;
}
return { unionId, clientId } as SessionPayload;
} catch (error) {
console.warn("[session] JWT verification failed:", error);
return null;
}
}
+18
View File
@@ -0,0 +1,18 @@
export type TokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope: string;
};
export type SessionPayload = {
unionId: string;
clientId: string;
};
export type UserProfile = {
user_id: string;
name: string;
avatar_url: string;
};
+17
View File
@@ -0,0 +1,17 @@
import type { CookieOptions } from "hono/utils/cookie";
function isLocalhost(headers: Headers): boolean {
const host = headers.get("host") || "";
return host.startsWith("localhost:") || host.startsWith("127.0.0.1:");
}
export function getSessionCookieOptions(headers: Headers): CookieOptions {
const localhost = isLocalhost(headers);
return {
httpOnly: true,
path: "/",
sameSite: localhost ? "Lax" : "None",
secure: !localhost,
};
}
+19
View File
@@ -0,0 +1,19 @@
import "dotenv/config";
function required(name: string): string {
const value = process.env[name];
if (!value && process.env.NODE_ENV === "production") {
throw new Error(`Missing required environment variable: ${name}`);
}
return value ?? "";
}
export const env = {
appId: required("APP_ID"),
appSecret: required("APP_SECRET"),
isProduction: process.env.NODE_ENV === "production",
databaseUrl: required("DATABASE_URL"),
kimiAuthUrl: required("KIMI_AUTH_URL"),
kimiOpenUrl: required("KIMI_OPEN_URL"),
ownerUnionId: process.env.OWNER_UNION_ID ?? "",
};
+77
View File
@@ -0,0 +1,77 @@
interface RequestConfig extends RequestInit {
baseUrl?: string;
params?: Record<string, string | number>;
timeout?: number;
}
export class HttpClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string, opts?: { headers?: Record<string, string> }) {
this.baseUrl = baseURL;
this.defaultHeaders = {
"Content-Type": "application/json",
...opts?.headers,
};
}
async request<T>(endpoint: string, config: RequestConfig = {}): Promise<T> {
const {
method = "GET",
params,
body,
headers,
timeout = 30000,
...rest
} = config;
const url = new URL(`${this.baseUrl}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) =>
url.searchParams.append(key, value.toString()),
);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url.toString(), {
...rest,
method,
headers: { ...this.defaultHeaders, ...headers },
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData = (await response
.json()
.catch(() => ({}))) as Record<string, string>;
throw new Error(errorData.message || `HTTP Error: ${response.status}`);
}
return (await response.json()) as T;
} catch (error: any) {
if (error.name === "AbortError") {
throw new Error("Request timeout");
}
throw error;
}
}
get<T>(
url: string,
params?: RequestConfig["params"],
config?: RequestConfig,
) {
return this.request<T>(url, { ...config, method: "GET", params });
}
post<T>(url: string, body?: any, config?: RequestConfig) {
return this.request<T>(url, { ...config, method: "POST", body });
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Hono } from "hono";
import type { HttpBindings } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import fs from "fs";
import path from "path";
type App = Hono<{ Bindings: HttpBindings }>;
export function serveStaticFiles(app: App) {
const distPath = path.resolve(import.meta.dirname, "../dist/public");
app.use("*", serveStatic({ root: "./dist/public" }));
app.notFound((c) => {
const accept = c.req.header("accept") ?? "";
if (!accept.includes("text/html")) {
return c.json({ error: "Not Found" }, 404);
}
const indexPath = path.resolve(distPath, "index.html");
const content = fs.readFileSync(indexPath, "utf-8");
return c.html(content);
});
}
+42
View File
@@ -0,0 +1,42 @@
import { ErrorMessages } from "@contracts/constants";
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { TrpcContext } from "./context";
const t = initTRPC.context<TrpcContext>().create({
transformer: superjson,
});
export const createRouter = t.router;
export const publicQuery = t.procedure;
const requireAuth = t.middleware(async (opts) => {
const { ctx, next } = opts;
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: ErrorMessages.unauthenticated,
});
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
function requireRole(role: string) {
return t.middleware(async (opts) => {
const { ctx, next } = opts;
if (!ctx.user || ctx.user.role !== role) {
throw new TRPCError({
code: "FORBIDDEN",
message: ErrorMessages.insufficientRole,
});
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
}
export const authedQuery = t.procedure.use(requireAuth);
export const adminQuery = authedQuery.use(requireRole("admin"));
+18
View File
@@ -0,0 +1,18 @@
import { drizzle } from "drizzle-orm/mysql2";
import { env } from "../lib/env";
import * as schema from "@db/schema";
import * as relations from "@db/relations";
const fullSchema = { ...schema, ...relations };
let instance: ReturnType<typeof drizzle<typeof fullSchema>>;
export function getDb() {
if (!instance) {
instance = drizzle(env.databaseUrl, {
mode: "planetscale",
schema: fullSchema,
});
}
return instance;
}
+36
View File
@@ -0,0 +1,36 @@
import { eq } from "drizzle-orm";
import * as schema from "@db/schema";
import type { InsertUser } from "@db/schema";
import { getDb } from "./connection";
import { env } from "../lib/env";
export async function findUserByUnionId(unionId: string) {
const rows = await getDb()
.select()
.from(schema.users)
.where(eq(schema.users.unionId, unionId))
.limit(1);
return rows.at(0);
}
export async function upsertUser(data: InsertUser) {
const values = { ...data };
const updateSet: Partial<InsertUser> = {
lastSignInAt: new Date(),
...data,
};
if (
values.role === undefined &&
values.unionId &&
values.unionId === env.ownerUnionId
) {
values.role = "admin";
updateSet.role = "admin";
}
await getDb()
.insert(schema.users)
.values(values)
.onDuplicateKeyUpdate({ set: updateSet });
}
+11
View File
@@ -0,0 +1,11 @@
import { authRouter } from "./auth-router";
import { formRouter } from "./form-router";
import { createRouter, publicQuery } from "./middleware";
export const appRouter = createRouter({
ping: publicQuery.query(() => ({ ok: true, ts: Date.now() })),
auth: authRouter,
form: formRouter,
});
export type AppRouter = typeof appRouter;