feat(db): migration and database setup

This commit is contained in:
2025-11-10 16:57:27 +02:00
parent 594b08a2d1
commit fbca02bec6
31 changed files with 2854 additions and 1928 deletions
+2
View File
@@ -1,4 +1,6 @@
NODE_ENV=development
BASANGO_API_HOST=localhost
BASANGO_API_PORT=3000
BASANGO_API_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
BASANGO_API_KEY=your_api_key_here
BASANGO_CRAWLER_KEY=dev
+1
View File
@@ -27,6 +27,7 @@ export const { env, config } = defineConfig({
"BASANGO_API_PORT",
"BASANGO_API_ALLOWED_ORIGINS",
"BASANGO_API_KEY",
"BASANGO_CRAWLER_KEY",
],
path: path.join(PROJECT_DIR, ".env"),
},
+3 -18
View File
@@ -4,7 +4,7 @@ import { cors } from "hono/cors";
import { secureHeaders } from "hono/secure-headers";
import { config, env } from "@/config";
import { checkHealth } from "@/utils/health";
import { routers } from "@/rest/routers";
const app = new OpenAPIHono();
@@ -21,26 +21,10 @@ app.use(
}),
);
app.get("/health", async (c) => {
try {
await checkHealth();
return c.json({ status: "ok" }, 200);
} catch (error) {
return c.json(
{
message: error instanceof Error ? error.message : "Unknown error",
status: "error",
},
500,
);
}
});
app.doc("/openapi", {
info: {
contact: {
email: "engineer@basango.io",
email: "engineering@basango.io",
name: "Basango",
url: "https://basango.io",
},
@@ -76,6 +60,7 @@ app.openAPIRegistry.registerComponent("securitySchemes", "token", {
});
app.get("/", Scalar({ pageTitle: "Basango API", theme: "saturn", url: "/openapi" }));
app.route("/", routers);
export default {
fetch: app.fetch,
+18
View File
@@ -0,0 +1,18 @@
import type { MiddlewareHandler } from "hono";
import { HTTPException } from "hono/http-exception";
import { env } from "@/config";
export const withCrawlerAuth: MiddlewareHandler = async (c, next) => {
const token = c.req.header("Authorization");
if (!token) {
throw new HTTPException(401, { message: "Authorization header required" });
}
if (token !== env("BASANGO_CRAWLER_KEY")) {
throw new HTTPException(403, { message: "Invalid token" });
}
await next();
};
+8
View File
@@ -0,0 +1,8 @@
import { db } from "@basango/db/client";
import type { MiddlewareHandler } from "hono";
export const withDatabase: MiddlewareHandler = async (c, next) => {
c.set("db", db);
await next();
};
+36
View File
@@ -0,0 +1,36 @@
import type { MiddlewareHandler } from "hono";
import type { Scope } from "@/utils/scopes";
export const withRequiredScope = (...requiredScopes: Scope[]): MiddlewareHandler => {
return async (c, next) => {
const scopes = c.get("scopes") as Scope[] | undefined;
if (!scopes) {
return c.json(
{
description: "No scopes found for the current user. Authentication is required.",
error: "Unauthorized",
},
401,
);
}
// Check if user has at least one of the required scopes
const hasRequiredScope = requiredScopes.some((requiredScope) => scopes.includes(requiredScope));
if (!hasRequiredScope) {
return c.json(
{
description: `Insufficient permissions. Required scopes: ${requiredScopes.join(
", ",
)}. Your scopes: ${scopes.join(", ")}`,
error: "Forbidden",
},
403,
);
}
await next();
};
};
+53
View File
@@ -0,0 +1,53 @@
import { createArticle } from "@basango/db/queries";
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { withCrawlerAuth } from "@/rest/middlewares/crawler";
import type { Context } from "@/rest/types";
import { createArticleResponseSchema, createArticleSchema } from "@/schemas/articles";
import { validateResponse } from "@/utils/response";
const app = new OpenAPIHono<Context>();
app.openapi(
createRoute({
description: "Store a new crawled article in the database.",
method: "post",
middleware: [withCrawlerAuth],
operationId: "CreateArticle",
path: "/",
request: {
body: {
content: {
"application/json": {
schema: createArticleSchema,
},
},
},
},
responses: {
201: {
content: {
"application/json": {
schema: createArticleResponseSchema,
},
},
description: "Article created",
},
},
summary: "Create Article",
tags: ["Articles"],
"x-speakeasy-name-override": "create",
}),
async (c) => {
const db = c.get("db");
const body = c.req.valid("json");
const result = await createArticle(db, { ...body });
return c.json(
validateResponse(result, createArticleResponseSchema) as { id: string; sourceId: string },
201,
);
},
);
export const articlesRouter = app;
+9
View File
@@ -0,0 +1,9 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { articlesRouter } from "@/rest/routers/articles";
const routers = new OpenAPIHono();
routers.route("/articles", articlesRouter);
export { routers };
+7
View File
@@ -0,0 +1,7 @@
import type { Database } from "@basango/db/client";
export type Context = {
Variables: {
db: Database;
};
};
+99
View File
@@ -0,0 +1,99 @@
import { z } from "@hono/zod-openapi";
const sentimentSchema = z.enum(["positive", "neutral", "negative"]).openapi({
default: "neutral",
description: "The sentiment of the article content.",
});
const readingTimeSchema = z.number().min(1).openapi({
description: "The estimated reading time of the article in minutes.",
example: 5,
});
const tokenStatisticsSchema = z.object({
body: z.number().min(0).openapi({
description: "The number of tokens in the article body.",
example: 350,
}),
categories: z.number().min(0).openapi({
description: "The number of tokens in the article categories.",
example: 25,
}),
excerpt: z.number().min(0).openapi({
description: "The number of tokens in the article excerpt.",
example: 50,
}),
title: z.number().min(0).openapi({
description: "The number of tokens in the article title.",
example: 15,
}),
total: z.number().min(0).openapi({
description: "The total number of tokens in the article.",
example: 440,
}),
});
const metadataSchema = z.object({
description: z.string().optional().openapi({
description: "A brief description or summary of the article.",
example: "This article discusses the latest advancements in AI technology.",
}),
image: z.url().optional().openapi({
description: "The URL of the main image associated with the article.",
example: "https://example.com/image.jpg",
}),
title: z.string().optional().openapi({
description: "The title of the article for metadata purposes.",
example: "The Rise of AI",
}),
});
export const createArticleSchema = z
.object({
body: z.string().min(1).openapi({
description: "The main content of the article.",
example: "This is the body of the article...",
}),
categories: z.array(z.string()).openapi({
description: "The categories or tags associated with the article.",
example: ["Technology", "AI"],
}),
hash: z.string().min(1).openapi({
description: "The unique hash of the article link.",
example: "d41d8cd98f00b204e9800998ecf8427e",
}),
link: z.url().openapi({
description: "The URL of the article.",
example: "https://example.com/article",
}),
metadata: metadataSchema.optional(),
publishedAt: z.date().openapi({
description: "The publication date of the article.",
example: "2023-01-01T00:00:00Z",
}),
readingTime: readingTimeSchema.optional(),
sentiment: sentimentSchema.optional().optional().default("neutral"),
sourceId: z.string().openapi({
description: "The unique identifier of the source from which the article was crawled.",
example: "source-123",
}),
title: z.string().min(1).openapi({
description: "The title of the article.",
example: "The Rise of AI",
}),
tokenStatistics: tokenStatisticsSchema.optional(),
})
.openapi("CreateArticle");
export const createArticleResponseSchema = z
.object({
id: z.uuid().openapi({
description: "The unique identifier of the article.",
example: "b3b7c8e2-1f2a-4c3d-9e4f-5a6b7c8d9e0f",
}),
sourceId: z.uuid().openapi({
description: "The unique identifier of the source associated with the article.",
example: "a1a2b3c4-d5e6-7f8g-9h0i-j1k2l3m4n5o6",
}),
})
.openapi("CreateArticleResponse");
-5
View File
@@ -1,5 +0,0 @@
import { checkHealth as checkDbHealth } from "@basango/db";
export async function checkHealth(): Promise<void> {
await checkDbHealth();
}
+66
View File
@@ -0,0 +1,66 @@
export const SCOPES = [
"articles.read",
"articles.write",
"apis.all", // All API scopes
"apis.read", // All read scopes
] as const;
export type Scope = (typeof SCOPES)[number];
export type ScopePreset = "all_access" | "read_only" | "restricted";
export const scopePresets = [
{
description: "full access to all resources",
label: "All",
value: "all_access",
},
{
description: "read-only access to all resources",
label: "Read Only",
value: "read_only",
},
{
description: "restricted access to some resources",
label: "Restricted",
value: "restricted",
},
];
export const scopesToName = (scopes: string[]) => {
if (scopes.includes("apis.all")) {
return {
description: "full access to all resources",
name: "All access",
preset: "all_access",
};
}
if (scopes.includes("apis.read")) {
return {
description: "read-only access to all resources",
name: "Read-only",
preset: "read_only",
};
}
return {
description: "restricted access to some resources",
name: "Restricted",
preset: "restricted",
};
};
export const expandScopes = (scopes: string[]): string[] => {
if (scopes.includes("apis.all")) {
// Return all scopes except any that start with "apis."
return SCOPES.filter((scope) => !scope.startsWith("apis."));
}
if (scopes.includes("apis.read")) {
// Return all read scopes except any that start with "apis."
return SCOPES.filter((scope) => scope.endsWith(".read") && !scope.startsWith("apis."));
}
// For custom scopes, filter out any "apis." scopes
return scopes.filter((scope) => !scope.startsWith("apis."));
};