diff --git a/apps/api/.env b/apps/api/.env index 40bd43e..6a6bcde 100644 --- a/apps/api/.env +++ b/apps/api/.env @@ -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 diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 119916c..29c709b 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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"), }, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1c84662..9192502 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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, diff --git a/apps/api/src/rest/middlewares/crawler.ts b/apps/api/src/rest/middlewares/crawler.ts new file mode 100644 index 0000000..43e9208 --- /dev/null +++ b/apps/api/src/rest/middlewares/crawler.ts @@ -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(); +}; diff --git a/apps/api/src/rest/middlewares/db.ts b/apps/api/src/rest/middlewares/db.ts new file mode 100644 index 0000000..9caddad --- /dev/null +++ b/apps/api/src/rest/middlewares/db.ts @@ -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(); +}; diff --git a/apps/api/src/rest/middlewares/scope.ts b/apps/api/src/rest/middlewares/scope.ts new file mode 100644 index 0000000..ea07677 --- /dev/null +++ b/apps/api/src/rest/middlewares/scope.ts @@ -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(); + }; +}; diff --git a/apps/api/src/rest/routers/articles.ts b/apps/api/src/rest/routers/articles.ts new file mode 100644 index 0000000..030210d --- /dev/null +++ b/apps/api/src/rest/routers/articles.ts @@ -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(); + +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; diff --git a/apps/api/src/rest/routers/index.ts b/apps/api/src/rest/routers/index.ts new file mode 100644 index 0000000..9cb2f41 --- /dev/null +++ b/apps/api/src/rest/routers/index.ts @@ -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 }; diff --git a/apps/api/src/rest/types.ts b/apps/api/src/rest/types.ts new file mode 100644 index 0000000..b55349e --- /dev/null +++ b/apps/api/src/rest/types.ts @@ -0,0 +1,7 @@ +import type { Database } from "@basango/db/client"; + +export type Context = { + Variables: { + db: Database; + }; +}; diff --git a/apps/api/src/schemas/articles.ts b/apps/api/src/schemas/articles.ts new file mode 100644 index 0000000..4af58b3 --- /dev/null +++ b/apps/api/src/schemas/articles.ts @@ -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"); diff --git a/apps/api/src/utils/health.ts b/apps/api/src/utils/health.ts deleted file mode 100644 index 4c30c1f..0000000 --- a/apps/api/src/utils/health.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { checkHealth as checkDbHealth } from "@basango/db"; - -export async function checkHealth(): Promise { - await checkDbHealth(); -} diff --git a/apps/api/src/utils/validate-response.ts b/apps/api/src/utils/response.ts similarity index 100% rename from apps/api/src/utils/validate-response.ts rename to apps/api/src/utils/response.ts diff --git a/apps/api/src/utils/scopes.ts b/apps/api/src/utils/scopes.ts new file mode 100644 index 0000000..d72f5f0 --- /dev/null +++ b/apps/api/src/utils/scopes.ts @@ -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.")); +}; diff --git a/bun.lock b/bun.lock index 9af6b55..b071e8f 100644 --- a/bun.lock +++ b/bun.lock @@ -111,11 +111,15 @@ "packages/db": { "name": "@basango/db", "dependencies": { + "@basango/encryption": "workspace:*", "@basango/logger": "workspace:*", "@date-fns/utc": "^2.1.1", "drizzle-orm": "^0.44.7", + "mysql2": "^3.15.3", "pg": "^8.16.3", "snakecase-keys": "^9.0.2", + "tiktoken": "^1.0.22", + "uuid": "^13.0.0", }, "devDependencies": { "@types/pg": "^8.15.6", @@ -1028,6 +1032,8 @@ "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], "babel-plugin-istanbul": ["babel-plugin-istanbul@6.1.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-instrument": "^5.0.4", "test-exclude": "^6.0.0" } }, "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA=="], @@ -1426,6 +1432,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -1500,7 +1508,7 @@ "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], - "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -1556,6 +1564,8 @@ "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "is-text-path": ["is-text-path@2.0.0", "", { "dependencies": { "text-extensions": "^2.0.0" } }, "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw=="], @@ -1700,6 +1710,8 @@ "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "longest": ["longest@2.0.1", "", {}, "sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -1710,6 +1722,8 @@ "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "lru.min": ["lru.min@1.1.2", "", {}, "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg=="], + "lucide-react": ["lucide-react@0.475.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg=="], "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], @@ -1790,8 +1804,12 @@ "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="], + "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], @@ -2104,6 +2122,8 @@ "sentence-case": ["sentence-case@2.1.1", "", { "dependencies": { "no-case": "^2.2.0", "upper-case-first": "^1.1.2" } }, "sha512-ENl7cYHaK/Ktwk5OTD+aDbQ3uC8IByu/6Bkg+HDv8Mm+XnBnppVNalcfJTNsp1ibstKh030/JKQQWglDvtKwEQ=="], + "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + "serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], "serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="], @@ -2164,6 +2184,8 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -2346,7 +2368,7 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="], @@ -2590,6 +2612,8 @@ "bullmq/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], + "bullmq/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "chromium-edge-launcher/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], @@ -2616,6 +2640,8 @@ "expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], + "external-editor/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], "figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], diff --git a/packages/db/.env b/packages/db/.env index 5597a84..e1b025c 100644 --- a/packages/db/.env +++ b/packages/db/.env @@ -1 +1,6 @@ -BASANGO_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/app?serverVersion=16&charset=utf8" \ No newline at end of file +BASANGO_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/app?serverVersion=16&charset=utf8" + +BASANGO_SOURCE_DATABASE_HOST="localhost" +BASANGO_SOURCE_DATABASE_PASS="root" +BASANGO_SOURCE_DATABASE_NAME="app" +BASANGO_SOURCE_DATABASE_USER="root" diff --git a/packages/db/migrations/0000_aromatic_dorian_gray.sql b/packages/db/migrations/0000_aromatic_dorian_gray.sql deleted file mode 100644 index b036ff6..0000000 --- a/packages/db/migrations/0000_aromatic_dorian_gray.sql +++ /dev/null @@ -1,172 +0,0 @@ --- Current sql file was generated after introspecting the database --- If you want to run this migration please uncomment this code before executing migrations -/* -CREATE SEQUENCE "public"."refresh_tokens_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1;--> statement-breakpoint -CREATE TABLE "doctrine_migration_versions" ( - "version" varchar(191) PRIMARY KEY NOT NULL, - "executed_at" timestamp(0) DEFAULT NULL, - "execution_time" integer -); ---> statement-breakpoint -CREATE TABLE "bookmark" ( - "id" uuid PRIMARY KEY NOT NULL, - "user_id" uuid NOT NULL, - "name" varchar(255) NOT NULL, - "description" varchar(512) DEFAULT NULL, - "is_public" boolean DEFAULT false NOT NULL, - "created_at" timestamp(0) NOT NULL, - "updated_at" timestamp(0) DEFAULT NULL -); ---> statement-breakpoint -CREATE TABLE "login_attempt" ( - "id" uuid PRIMARY KEY NOT NULL, - "user_id" uuid NOT NULL, - "created_at" timestamp(0) NOT NULL -); ---> statement-breakpoint -CREATE TABLE "login_history" ( - "id" uuid PRIMARY KEY NOT NULL, - "user_id" uuid NOT NULL, - "ip_address" "inet", - "created_at" timestamp(0) NOT NULL, - "device_operating_system" varchar(255) DEFAULT NULL, - "device_client" varchar(255) DEFAULT NULL, - "device_device" varchar(255) DEFAULT NULL, - "device_is_bot" boolean DEFAULT false NOT NULL, - "location_time_zone" varchar(255) DEFAULT NULL, - "location_longitude" double precision, - "location_latitude" double precision, - "location_accuracy_radius" integer -); ---> statement-breakpoint -CREATE TABLE "verification_token" ( - "id" uuid PRIMARY KEY NOT NULL, - "user_id" uuid NOT NULL, - "purpose" varchar(255) NOT NULL, - "created_at" timestamp(0) NOT NULL, - "token" varchar(60) DEFAULT NULL -); ---> statement-breakpoint -CREATE TABLE "followed_source" ( - "id" uuid PRIMARY KEY NOT NULL, - "follower_id" uuid NOT NULL, - "source_id" uuid NOT NULL, - "created_at" timestamp(0) NOT NULL -); ---> statement-breakpoint -CREATE TABLE "comment" ( - "id" uuid PRIMARY KEY NOT NULL, - "user_id" uuid NOT NULL, - "article_id" uuid NOT NULL, - "content" varchar(512) NOT NULL, - "sentiment" varchar(30) DEFAULT 'neutral' NOT NULL, - "is_spam" boolean DEFAULT false NOT NULL, - "created_at" timestamp(0) NOT NULL -); ---> statement-breakpoint -CREATE TABLE "refresh_tokens" ( - "id" integer PRIMARY KEY NOT NULL, - "refresh_token" varchar(128) NOT NULL, - "username" varchar(255) NOT NULL, - "valid" timestamp(0) NOT NULL -); ---> statement-breakpoint -CREATE TABLE "article" ( - "id" uuid PRIMARY KEY NOT NULL, - "source_id" uuid NOT NULL, - "title" varchar(1024) NOT NULL, - "body" text NOT NULL, - "hash" varchar(32) NOT NULL, - "categories" text[], - "sentiment" varchar(30) DEFAULT 'neutral' NOT NULL, - "metadata" jsonb, - "image" varchar(1024) GENERATED ALWAYS AS ((metadata ->> 'image'::text)) STORED, - "excerpt" varchar(255) GENERATED ALWAYS AS (("left"(body, 200) || '...'::text)) STORED, - "published_at" timestamp(0) NOT NULL, - "crawled_at" timestamp(0) NOT NULL, - "updated_at" timestamp(0) DEFAULT NULL, - "link" varchar(1024) NOT NULL, - "bias" varchar(30) DEFAULT 'neutral' NOT NULL, - "reliability" varchar(30) DEFAULT 'reliable' NOT NULL, - "transparency" varchar(30) DEFAULT 'medium' NOT NULL, - "reading_time" integer DEFAULT 1, - "tsv" "tsvector" GENERATED ALWAYS AS ((setweight(to_tsvector('french'::regconfig, (COALESCE(title, ''::character varying))::text), 'A'::"char") || setweight(to_tsvector('french'::regconfig, COALESCE(body, ''::text)), 'B'::"char"))) STORED, - "token_statistics" jsonb, - CONSTRAINT "chk_article_reading_time" CHECK (reading_time >= 0), - CONSTRAINT "chk_article_sentiment" CHECK ((sentiment)::text = ANY ((ARRAY['positive'::character varying, 'neutral'::character varying, 'negative'::character varying])::text[])), - CONSTRAINT "chk_article_metadata_json" CHECK ((metadata IS NULL) OR (jsonb_typeof(metadata) = ANY (ARRAY['object'::text, 'array'::text]))) -); ---> statement-breakpoint -CREATE TABLE "user" ( - "id" uuid PRIMARY KEY NOT NULL, - "name" varchar(255) NOT NULL, - "email" varchar(255) NOT NULL, - "password" varchar(512) NOT NULL, - "is_locked" boolean DEFAULT false NOT NULL, - "is_confirmed" boolean DEFAULT false NOT NULL, - "created_at" timestamp(0) NOT NULL, - "updated_at" timestamp(0) DEFAULT NULL, - "roles" jsonb NOT NULL, - CONSTRAINT "chk_user_roles_json" CHECK (jsonb_typeof(roles) = 'array'::text) -); ---> statement-breakpoint -CREATE TABLE "source" ( - "id" uuid PRIMARY KEY NOT NULL, - "url" varchar(255) NOT NULL, - "name" varchar(255) NOT NULL, - "display_name" varchar(255) DEFAULT NULL, - "description" varchar(1024) DEFAULT NULL, - "updated_at" timestamp(0) DEFAULT NULL, - "bias" varchar(30) DEFAULT 'neutral' NOT NULL, - "reliability" varchar(30) DEFAULT 'reliable' NOT NULL, - "transparency" varchar(30) DEFAULT 'medium' NOT NULL -); ---> statement-breakpoint -CREATE TABLE "bookmark_article" ( - "bookmark_id" uuid NOT NULL, - "article_id" uuid NOT NULL, - CONSTRAINT "bookmark_article_pkey" PRIMARY KEY("bookmark_id","article_id") -); ---> statement-breakpoint -ALTER TABLE "bookmark" ADD CONSTRAINT "fk_da62921da76ed395" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "login_attempt" ADD CONSTRAINT "fk_8c11c1ba76ed395" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "login_history" ADD CONSTRAINT "fk_37976e36a76ed395" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "verification_token" ADD CONSTRAINT "fk_c1cc006ba76ed395" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "followed_source" ADD CONSTRAINT "fk_7a763a3eac24f853" FOREIGN KEY ("follower_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "followed_source" ADD CONSTRAINT "fk_7a763a3e953c1c61" FOREIGN KEY ("source_id") REFERENCES "public"."source"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "comment" ADD CONSTRAINT "fk_9474526ca76ed395" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "comment" ADD CONSTRAINT "fk_9474526c7294869c" FOREIGN KEY ("article_id") REFERENCES "public"."article"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "article" ADD CONSTRAINT "fk_23a0e66953c1c61" FOREIGN KEY ("source_id") REFERENCES "public"."source"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "bookmark_article" ADD CONSTRAINT "fk_6fe2655d92741d25" FOREIGN KEY ("bookmark_id") REFERENCES "public"."bookmark"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "bookmark_article" ADD CONSTRAINT "fk_6fe2655d7294869c" FOREIGN KEY ("article_id") REFERENCES "public"."article"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "idx_bookmark_user_created" ON "bookmark" USING btree ("user_id" timestamp_ops,"created_at" timestamp_ops);--> statement-breakpoint -CREATE INDEX "idx_da62921da76ed395" ON "bookmark" USING btree ("user_id" uuid_ops);--> statement-breakpoint -CREATE INDEX "idx_8c11c1ba76ed395" ON "login_attempt" USING btree ("user_id" uuid_ops);--> statement-breakpoint -CREATE INDEX "idx_login_attempt_created_at" ON "login_attempt" USING btree ("created_at" timestamp_ops);--> statement-breakpoint -CREATE INDEX "idx_37976e36a76ed395" ON "login_history" USING btree ("user_id" uuid_ops);--> statement-breakpoint -CREATE INDEX "idx_login_history_created_at" ON "login_history" USING btree ("user_id" uuid_ops,"created_at" timestamp_ops);--> statement-breakpoint -CREATE INDEX "idx_login_history_ip_address" ON "login_history" USING btree ("ip_address" inet_ops);--> statement-breakpoint -CREATE INDEX "idx_c1cc006ba76ed395" ON "verification_token" USING btree ("user_id" uuid_ops);--> statement-breakpoint -CREATE INDEX "idx_verif_token_created_at" ON "verification_token" USING btree ("created_at" timestamp_ops);--> statement-breakpoint -CREATE UNIQUE INDEX "unq_verif_user_purpose_token" ON "verification_token" USING btree ("user_id" text_ops,"purpose" text_ops) WHERE (token IS NOT NULL);--> statement-breakpoint -CREATE INDEX "idx_7a763a3e953c1c61" ON "followed_source" USING btree ("source_id" uuid_ops);--> statement-breakpoint -CREATE INDEX "idx_7a763a3eac24f853" ON "followed_source" USING btree ("follower_id" uuid_ops);--> statement-breakpoint -CREATE INDEX "idx_followed_source_follower_created" ON "followed_source" USING btree ("follower_id" timestamp_ops,"created_at" uuid_ops);--> statement-breakpoint -CREATE INDEX "idx_9474526c7294869c" ON "comment" USING btree ("article_id" uuid_ops);--> statement-breakpoint -CREATE INDEX "idx_9474526ca76ed395" ON "comment" USING btree ("user_id" uuid_ops);--> statement-breakpoint -CREATE INDEX "idx_comment_article_created" ON "comment" USING btree ("article_id" timestamp_ops,"created_at" uuid_ops);--> statement-breakpoint -CREATE UNIQUE INDEX "uniq_9bace7e1c74f2195" ON "refresh_tokens" USING btree ("refresh_token" text_ops);--> statement-breakpoint -CREATE INDEX "gin_article_categories" ON "article" USING gin ("categories" array_ops);--> statement-breakpoint -CREATE INDEX "gin_article_link_trgm" ON "article" USING gin ("link" gin_trgm_ops);--> statement-breakpoint -CREATE INDEX "gin_article_title_trgm" ON "article" USING gin ("title" gin_trgm_ops);--> statement-breakpoint -CREATE INDEX "gin_article_tsv" ON "article" USING gin ("tsv" tsvector_ops);--> statement-breakpoint -CREATE INDEX "idx_23a0e66953c1c61" ON "article" USING btree ("source_id" uuid_ops);--> statement-breakpoint -CREATE INDEX "idx_article_published_at" ON "article" USING btree ("published_at" timestamp_ops);--> statement-breakpoint -CREATE INDEX "idx_article_published_id" ON "article" USING btree ("published_at" timestamp_ops,"id" uuid_ops);--> statement-breakpoint -CREATE UNIQUE INDEX "unq_article_hash" ON "article" USING btree ("hash" text_ops);--> statement-breakpoint -CREATE UNIQUE INDEX "unq_user_email" ON "user" USING btree (lower((email)::text) text_ops);--> statement-breakpoint -CREATE UNIQUE INDEX "unq_source_name" ON "source" USING btree (lower((name)::text) text_ops);--> statement-breakpoint -CREATE UNIQUE INDEX "unq_source_url" ON "source" USING btree (lower((url)::text) text_ops);--> statement-breakpoint -CREATE INDEX "idx_6fe2655d7294869c" ON "bookmark_article" USING btree ("article_id" uuid_ops);--> statement-breakpoint -CREATE INDEX "idx_6fe2655d92741d25" ON "bookmark_article" USING btree ("bookmark_id" uuid_ops); -*/ \ No newline at end of file diff --git a/packages/db/migrations/0000_setup.sql b/packages/db/migrations/0000_setup.sql new file mode 100644 index 0000000..fea2b4c --- /dev/null +++ b/packages/db/migrations/0000_setup.sql @@ -0,0 +1,3 @@ +-- Custom SQL migration file, put your code below! -- +CREATE EXTENSION IF NOT EXISTS pg_trgm; +SET SESSION TIME ZONE 'UTC'; diff --git a/packages/db/migrations/0001_init.sql b/packages/db/migrations/0001_init.sql new file mode 100644 index 0000000..0a53066 --- /dev/null +++ b/packages/db/migrations/0001_init.sql @@ -0,0 +1,154 @@ +CREATE TYPE "public"."bias" AS ENUM('neutral', 'slightly', 'partisan', 'extreme');--> statement-breakpoint +CREATE TYPE "public"."reliability" AS ENUM('trusted', 'reliable', 'average', 'low_trust', 'unreliable');--> statement-breakpoint +CREATE TYPE "public"."sentiment" AS ENUM('positive', 'neutral', 'negative');--> statement-breakpoint +CREATE TYPE "public"."token_purpose" AS ENUM('confirm_account', 'password_reset', 'unlock_account', 'delete_account');--> statement-breakpoint +CREATE TYPE "public"."transparency" AS ENUM('high', 'medium', 'low');--> statement-breakpoint +CREATE TABLE "article" ( + "body" text NOT NULL, + "categories" text[], + "crawled_at" timestamp DEFAULT now() NOT NULL, + "credibility" jsonb, + "excerpt" varchar(255) GENERATED ALWAYS AS (("left"(body, 200) || '...'::text)) STORED, + "hash" varchar(32) NOT NULL, + "id" uuid PRIMARY KEY NOT NULL, + "image" varchar(1024) GENERATED ALWAYS AS ((metadata ->> 'image'::text)) STORED, + "link" varchar(1024) NOT NULL, + "metadata" jsonb, + "published_at" timestamp NOT NULL, + "reading_time" integer DEFAULT 1, + "sentiment" "sentiment" NOT NULL, + "source_id" uuid NOT NULL, + "title" varchar(1024) NOT NULL, + "token_statistics" jsonb, + "tsv" "tsvector" GENERATED ALWAYS AS (( + setweight(to_tsvector('french'::regconfig, COALESCE(title, '')::text), 'A'::"char") + || setweight(to_tsvector('french'::regconfig, COALESCE(body, ''::text)), 'B'::"char") + )) STORED, + "updated_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "bookmark" ( + "created_at" timestamp DEFAULT now() NOT NULL, + "description" varchar(512), + "id" uuid PRIMARY KEY NOT NULL, + "is_public" boolean DEFAULT false NOT NULL, + "name" varchar(255) NOT NULL, + "updated_at" timestamp, + "user_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "bookmark_article" ( + "article_id" uuid NOT NULL, + "bookmark_id" uuid NOT NULL, + CONSTRAINT "bookmark_article_pkey" PRIMARY KEY("bookmark_id","article_id") +); +--> statement-breakpoint +CREATE TABLE "comment" ( + "article_id" uuid NOT NULL, + "content" varchar(512) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "id" uuid PRIMARY KEY NOT NULL, + "is_spam" boolean DEFAULT false NOT NULL, + "sentiment" "sentiment" NOT NULL, + "user_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "followed_source" ( + "created_at" timestamp DEFAULT now() NOT NULL, + "follower_id" uuid NOT NULL, + "id" uuid PRIMARY KEY NOT NULL, + "source_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "login_attempt" ( + "created_at" timestamp DEFAULT now() NOT NULL, + "id" uuid PRIMARY KEY NOT NULL, + "user_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "login_history" ( + "created_at" timestamp DEFAULT now() NOT NULL, + "device" jsonb, + "id" uuid PRIMARY KEY NOT NULL, + "ip_address" "inet", + "location" jsonb, + "user_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "refresh_token" ( + "id" uuid PRIMARY KEY NOT NULL, + "token" varchar(128) NOT NULL, + "username" varchar(255) NOT NULL, + "valid" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "source" ( + "credibility" jsonb, + "description" varchar(1024), + "display_name" varchar(255), + "id" uuid PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL, + "updated_at" timestamp, + "url" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user" ( + "created_at" timestamp DEFAULT now() NOT NULL, + "email" varchar(255) NOT NULL, + "id" uuid PRIMARY KEY NOT NULL, + "is_confirmed" boolean DEFAULT false NOT NULL, + "is_locked" boolean DEFAULT false NOT NULL, + "name" varchar(255) NOT NULL, + "password" varchar(512) NOT NULL, + "roles" varchar(255)[] DEFAULT '{"ROLE_USER"}' NOT NULL, + "updated_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "verification_token" ( + "created_at" timestamp DEFAULT now() NOT NULL, + "id" uuid PRIMARY KEY NOT NULL, + "purpose" "token_purpose" NOT NULL, + "token" varchar(60), + "user_id" uuid NOT NULL +); +--> statement-breakpoint +ALTER TABLE "article" ADD CONSTRAINT "fk_article_source_id" FOREIGN KEY ("source_id") REFERENCES "public"."source"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bookmark" ADD CONSTRAINT "fk_bookmark_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bookmark_article" ADD CONSTRAINT "fk_bookmark_article_bookmark_id" FOREIGN KEY ("bookmark_id") REFERENCES "public"."bookmark"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bookmark_article" ADD CONSTRAINT "fk_bookmark_article_article_id" FOREIGN KEY ("article_id") REFERENCES "public"."article"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comment" ADD CONSTRAINT "fk_comment_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comment" ADD CONSTRAINT "fk_comment_article_id" FOREIGN KEY ("article_id") REFERENCES "public"."article"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "followed_source" ADD CONSTRAINT "fk_followed_source_follower_id" FOREIGN KEY ("follower_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "followed_source" ADD CONSTRAINT "fk_followed_source_source_id" FOREIGN KEY ("source_id") REFERENCES "public"."source"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "login_attempt" ADD CONSTRAINT "fk_login_attempt_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "login_history" ADD CONSTRAINT "fk_login_history_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "verification_token" ADD CONSTRAINT "fk_verification_token_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "gin_article_categories" ON "article" USING gin ("categories" array_ops);--> statement-breakpoint +CREATE INDEX "gin_article_link_trgm" ON "article" USING gin ("link" gin_trgm_ops);--> statement-breakpoint +CREATE INDEX "gin_article_title_trgm" ON "article" USING gin ("title" gin_trgm_ops);--> statement-breakpoint +CREATE INDEX "gin_article_tsv" ON "article" USING gin ("tsv" tsvector_ops);--> statement-breakpoint +CREATE INDEX "idx_article_source_published_id" ON "article" USING btree ("source_id","published_at" DESC NULLS FIRST,"id" DESC NULLS FIRST);--> statement-breakpoint +CREATE UNIQUE INDEX "unq_article_hash" ON "article" USING btree ("hash");--> statement-breakpoint +CREATE INDEX "idx_bookmark_user_created" ON "bookmark" USING btree ("user_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE UNIQUE INDEX "unq_bookmark_user_name" ON "bookmark" USING btree ("user_id",lower("name"));--> statement-breakpoint +CREATE INDEX "idx_bookmark_article_bookmark_id" ON "bookmark_article" USING btree ("bookmark_id");--> statement-breakpoint +CREATE INDEX "idx_comment_article_id" ON "comment" USING btree ("article_id");--> statement-breakpoint +CREATE INDEX "idx_comment_user_id" ON "comment" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "idx_comment_article_created" ON "comment" USING btree ("article_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "idx_followed_source_source_id" ON "followed_source" USING btree ("source_id");--> statement-breakpoint +CREATE INDEX "idx_followed_source_follower_id" ON "followed_source" USING btree ("follower_id");--> statement-breakpoint +CREATE INDEX "idx_followed_source_follower_created" ON "followed_source" USING btree ("follower_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE UNIQUE INDEX "unq_followed_source_user_source" ON "followed_source" USING btree ("follower_id","source_id");--> statement-breakpoint +CREATE INDEX "idx_login_attempt_user_created" ON "login_attempt" USING btree ("user_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "idx_login_history_user_created" ON "login_history" USING btree ("user_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "idx_login_history_ip_address" ON "login_history" USING btree ("ip_address");--> statement-breakpoint +CREATE UNIQUE INDEX "uniq_refresh_token_token" ON "refresh_token" USING btree ("token");--> statement-breakpoint +CREATE INDEX "idx_refresh_token_valid" ON "refresh_token" USING btree ("valid");--> statement-breakpoint +CREATE INDEX "idx_refresh_token_username" ON "refresh_token" USING btree (lower("username"));--> statement-breakpoint +CREATE UNIQUE INDEX "unq_source_name" ON "source" USING btree (lower((name)::text));--> statement-breakpoint +CREATE UNIQUE INDEX "unq_source_url" ON "source" USING btree (lower((url)::text));--> statement-breakpoint +CREATE UNIQUE INDEX "unq_user_email" ON "user" USING btree (lower((email)::text));--> statement-breakpoint +CREATE INDEX "idx_user_created_at" ON "user" USING btree (created_at);--> statement-breakpoint +CREATE INDEX "idx_verif_token_created_at" ON "verification_token" USING btree ("created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE UNIQUE INDEX "unq_verif_user_purpose_token" ON "verification_token" USING btree ("user_id","purpose","token") WHERE "verification_token"."token" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "unq_verif_token_token" ON "verification_token" USING btree ("token") WHERE "verification_token"."token" IS NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json index 779f6e0..2950f0a 100644 --- a/packages/db/migrations/meta/0000_snapshot.json +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -6,1481 +6,13 @@ }, "dialect": "postgresql", "enums": {}, - "id": "00000000-0000-0000-0000-000000000000", - "internal": { - "tables": { - "article": { - "columns": { - "categories": { - "dimensions": 1, - "isArray": true, - "rawType": "text" - }, - "updated_at": { - "isDefaultAnExpression": true - } - } - }, - "bookmark": { - "columns": { - "description": { - "isDefaultAnExpression": true - }, - "updated_at": { - "isDefaultAnExpression": true - } - } - }, - "doctrine_migration_versions": { - "columns": { - "executed_at": { - "isDefaultAnExpression": true - } - } - }, - "login_history": { - "columns": { - "device_client": { - "isDefaultAnExpression": true - }, - "device_device": { - "isDefaultAnExpression": true - }, - "device_operating_system": { - "isDefaultAnExpression": true - }, - "location_time_zone": { - "isDefaultAnExpression": true - } - } - }, - "source": { - "columns": { - "description": { - "isDefaultAnExpression": true - }, - "display_name": { - "isDefaultAnExpression": true - }, - "updated_at": { - "isDefaultAnExpression": true - } - } - }, - "user": { - "columns": { - "updated_at": { - "isDefaultAnExpression": true - } - } - }, - "verification_token": { - "columns": { - "token": { - "isDefaultAnExpression": true - } - } - } - } - }, + "id": "27985f7b-ced7-4c97-8e08-40f939e7e28c", "policies": {}, - "prevId": "", + "prevId": "00000000-0000-0000-0000-000000000000", "roles": {}, "schemas": {}, - "sequences": { - "public.refresh_tokens_id_seq": { - "cache": "1", - "cycle": false, - "increment": "1", - "maxValue": "9223372036854775807", - "minValue": "1", - "name": "refresh_tokens_id_seq", - "schema": "public", - "startWith": "1" - } - }, - "tables": { - "public.article": { - "checkConstraints": { - "chk_article_metadata_json": { - "name": "chk_article_metadata_json", - "value": "(metadata IS NULL) OR (jsonb_typeof(metadata) = ANY (ARRAY['object'::text, 'array'::text]))" - }, - "chk_article_reading_time": { - "name": "chk_article_reading_time", - "value": "reading_time >= 0" - }, - "chk_article_sentiment": { - "name": "chk_article_sentiment", - "value": "(sentiment)::text = ANY ((ARRAY['positive'::character varying, 'neutral'::character varying, 'negative'::character varying])::text[])" - } - }, - "columns": { - "bias": { - "default": "'neutral'", - "name": "bias", - "notNull": true, - "primaryKey": false, - "type": "varchar(30)" - }, - "body": { - "name": "body", - "notNull": true, - "primaryKey": false, - "type": "text" - }, - "categories": { - "name": "categories", - "notNull": false, - "primaryKey": false, - "type": "text[]" - }, - "crawled_at": { - "name": "crawled_at", - "notNull": true, - "primaryKey": false, - "type": "timestamp(0)" - }, - "excerpt": { - "generated": { - "as": "(\"left\"(body, 200) || '...'::text)", - "type": "stored" - }, - "name": "excerpt", - "notNull": false, - "primaryKey": false, - "type": "varchar(255)" - }, - "hash": { - "name": "hash", - "notNull": true, - "primaryKey": false, - "type": "varchar(32)" - }, - "id": { - "name": "id", - "notNull": true, - "primaryKey": true, - "type": "uuid" - }, - "image": { - "generated": { - "as": "(metadata ->> 'image'::text)", - "type": "stored" - }, - "name": "image", - "notNull": false, - "primaryKey": false, - "type": "varchar(1024)" - }, - "link": { - "name": "link", - "notNull": true, - "primaryKey": false, - "type": "varchar(1024)" - }, - "metadata": { - "name": "metadata", - "notNull": false, - "primaryKey": false, - "type": "jsonb" - }, - "published_at": { - "name": "published_at", - "notNull": true, - "primaryKey": false, - "type": "timestamp(0)" - }, - "reading_time": { - "default": 1, - "name": "reading_time", - "notNull": false, - "primaryKey": false, - "type": "integer" - }, - "reliability": { - "default": "'reliable'", - "name": "reliability", - "notNull": true, - "primaryKey": false, - "type": "varchar(30)" - }, - "sentiment": { - "default": "'neutral'", - "name": "sentiment", - "notNull": true, - "primaryKey": false, - "type": "varchar(30)" - }, - "source_id": { - "name": "source_id", - "notNull": true, - "primaryKey": false, - "type": "uuid" - }, - "title": { - "name": "title", - "notNull": true, - "primaryKey": false, - "type": "varchar(1024)" - }, - "token_statistics": { - "name": "token_statistics", - "notNull": false, - "primaryKey": false, - "type": "jsonb" - }, - "transparency": { - "default": "'medium'", - "name": "transparency", - "notNull": true, - "primaryKey": false, - "type": "varchar(30)" - }, - "tsv": { - "generated": { - "as": "(setweight(to_tsvector('french'::regconfig, (COALESCE(title, ''::character varying))::text), 'A'::\"char\") || setweight(to_tsvector('french'::regconfig, COALESCE(body, ''::text)), 'B'::\"char\"))", - "type": "stored" - }, - "name": "tsv", - "notNull": false, - "primaryKey": false, - "type": "tsvector" - }, - "updated_at": { - "default": "NULL", - "name": "updated_at", - "notNull": false, - "primaryKey": false, - "type": "timestamp(0)" - } - }, - "compositePrimaryKeys": {}, - "foreignKeys": { - "fk_23a0e66953c1c61": { - "columnsFrom": ["source_id"], - "columnsTo": ["id"], - "name": "fk_23a0e66953c1c61", - "onDelete": "cascade", - "onUpdate": "no action", - "schemaTo": "public", - "tableFrom": "article", - "tableTo": "source" - } - }, - "indexes": { - "gin_article_categories": { - "columns": [ - { - "asc": true, - "expression": "categories", - "isExpression": false, - "nulls": "last", - "opclass": "array_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "gin", - "name": "gin_article_categories", - "with": {} - }, - "gin_article_link_trgm": { - "columns": [ - { - "asc": true, - "expression": "link", - "isExpression": false, - "nulls": "last", - "opclass": "gin_trgm_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "gin", - "name": "gin_article_link_trgm", - "with": {} - }, - "gin_article_title_trgm": { - "columns": [ - { - "asc": true, - "expression": "title", - "isExpression": false, - "nulls": "last", - "opclass": "gin_trgm_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "gin", - "name": "gin_article_title_trgm", - "with": {} - }, - "gin_article_tsv": { - "columns": [ - { - "asc": true, - "expression": "tsv", - "isExpression": false, - "nulls": "last", - "opclass": "tsvector_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "gin", - "name": "gin_article_tsv", - "with": {} - }, - "idx_23a0e66953c1c61": { - "columns": [ - { - "asc": true, - "expression": "source_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_23a0e66953c1c61", - "with": {} - }, - "idx_article_published_at": { - "columns": [ - { - "asc": false, - "expression": "published_at", - "isExpression": false, - "nulls": "first", - "opclass": "timestamp_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_article_published_at", - "with": {} - }, - "idx_article_published_id": { - "columns": [ - { - "asc": false, - "expression": "published_at", - "isExpression": false, - "nulls": "first", - "opclass": "timestamp_ops" - }, - { - "asc": false, - "expression": "id", - "isExpression": false, - "nulls": "first", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_article_published_id", - "with": {} - }, - "unq_article_hash": { - "columns": [ - { - "asc": true, - "expression": "hash", - "isExpression": false, - "nulls": "last", - "opclass": "text_ops" - } - ], - "concurrently": false, - "isUnique": true, - "method": "btree", - "name": "unq_article_hash", - "with": {} - } - }, - "isRLSEnabled": false, - "name": "article", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - }, - "public.bookmark": { - "checkConstraints": {}, - "columns": { - "created_at": { - "name": "created_at", - "notNull": true, - "primaryKey": false, - "type": "timestamp(0)" - }, - "description": { - "default": "NULL", - "name": "description", - "notNull": false, - "primaryKey": false, - "type": "varchar(512)" - }, - "id": { - "name": "id", - "notNull": true, - "primaryKey": true, - "type": "uuid" - }, - "is_public": { - "default": false, - "name": "is_public", - "notNull": true, - "primaryKey": false, - "type": "boolean" - }, - "name": { - "name": "name", - "notNull": true, - "primaryKey": false, - "type": "varchar(255)" - }, - "updated_at": { - "default": "NULL", - "name": "updated_at", - "notNull": false, - "primaryKey": false, - "type": "timestamp(0)" - }, - "user_id": { - "name": "user_id", - "notNull": true, - "primaryKey": false, - "type": "uuid" - } - }, - "compositePrimaryKeys": {}, - "foreignKeys": { - "fk_da62921da76ed395": { - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "name": "fk_da62921da76ed395", - "onDelete": "cascade", - "onUpdate": "no action", - "schemaTo": "public", - "tableFrom": "bookmark", - "tableTo": "user" - } - }, - "indexes": { - "idx_bookmark_user_created": { - "columns": [ - { - "asc": true, - "expression": "user_id", - "isExpression": false, - "nulls": "last", - "opclass": "timestamp_ops" - }, - { - "asc": false, - "expression": "created_at", - "isExpression": false, - "nulls": "first", - "opclass": "timestamp_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_bookmark_user_created", - "with": {} - }, - "idx_da62921da76ed395": { - "columns": [ - { - "asc": true, - "expression": "user_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_da62921da76ed395", - "with": {} - } - }, - "isRLSEnabled": false, - "name": "bookmark", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - }, - "public.bookmark_article": { - "checkConstraints": {}, - "columns": { - "article_id": { - "name": "article_id", - "notNull": true, - "primaryKey": false, - "type": "uuid" - }, - "bookmark_id": { - "name": "bookmark_id", - "notNull": true, - "primaryKey": false, - "type": "uuid" - } - }, - "compositePrimaryKeys": { - "bookmark_article_pkey": { - "columns": ["bookmark_id", "article_id"], - "name": "bookmark_article_pkey" - } - }, - "foreignKeys": { - "fk_6fe2655d92741d25": { - "columnsFrom": ["bookmark_id"], - "columnsTo": ["id"], - "name": "fk_6fe2655d92741d25", - "onDelete": "cascade", - "onUpdate": "no action", - "schemaTo": "public", - "tableFrom": "bookmark_article", - "tableTo": "bookmark" - }, - "fk_6fe2655d7294869c": { - "columnsFrom": ["article_id"], - "columnsTo": ["id"], - "name": "fk_6fe2655d7294869c", - "onDelete": "cascade", - "onUpdate": "no action", - "schemaTo": "public", - "tableFrom": "bookmark_article", - "tableTo": "article" - } - }, - "indexes": { - "idx_6fe2655d92741d25": { - "columns": [ - { - "asc": true, - "expression": "bookmark_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_6fe2655d92741d25", - "with": {} - }, - "idx_6fe2655d7294869c": { - "columns": [ - { - "asc": true, - "expression": "article_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_6fe2655d7294869c", - "with": {} - } - }, - "isRLSEnabled": false, - "name": "bookmark_article", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - }, - "public.comment": { - "checkConstraints": {}, - "columns": { - "article_id": { - "name": "article_id", - "notNull": true, - "primaryKey": false, - "type": "uuid" - }, - "content": { - "name": "content", - "notNull": true, - "primaryKey": false, - "type": "varchar(512)" - }, - "created_at": { - "name": "created_at", - "notNull": true, - "primaryKey": false, - "type": "timestamp(0)" - }, - "id": { - "name": "id", - "notNull": true, - "primaryKey": true, - "type": "uuid" - }, - "is_spam": { - "default": false, - "name": "is_spam", - "notNull": true, - "primaryKey": false, - "type": "boolean" - }, - "sentiment": { - "default": "'neutral'", - "name": "sentiment", - "notNull": true, - "primaryKey": false, - "type": "varchar(30)" - }, - "user_id": { - "name": "user_id", - "notNull": true, - "primaryKey": false, - "type": "uuid" - } - }, - "compositePrimaryKeys": {}, - "foreignKeys": { - "fk_9474526c7294869c": { - "columnsFrom": ["article_id"], - "columnsTo": ["id"], - "name": "fk_9474526c7294869c", - "onDelete": "cascade", - "onUpdate": "no action", - "schemaTo": "public", - "tableFrom": "comment", - "tableTo": "article" - }, - "fk_9474526ca76ed395": { - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "name": "fk_9474526ca76ed395", - "onDelete": "cascade", - "onUpdate": "no action", - "schemaTo": "public", - "tableFrom": "comment", - "tableTo": "user" - } - }, - "indexes": { - "idx_9474526c7294869c": { - "columns": [ - { - "asc": true, - "expression": "article_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_9474526c7294869c", - "with": {} - }, - "idx_9474526ca76ed395": { - "columns": [ - { - "asc": true, - "expression": "user_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_9474526ca76ed395", - "with": {} - }, - "idx_comment_article_created": { - "columns": [ - { - "asc": true, - "expression": "article_id", - "isExpression": false, - "nulls": "last", - "opclass": "timestamp_ops" - }, - { - "asc": false, - "expression": "created_at", - "isExpression": false, - "nulls": "first", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_comment_article_created", - "with": {} - } - }, - "isRLSEnabled": false, - "name": "comment", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - }, - "public.doctrine_migration_versions": { - "checkConstraints": {}, - "columns": { - "executed_at": { - "default": "NULL", - "name": "executed_at", - "notNull": false, - "primaryKey": false, - "type": "timestamp(0)" - }, - "execution_time": { - "name": "execution_time", - "notNull": false, - "primaryKey": false, - "type": "integer" - }, - "version": { - "name": "version", - "notNull": true, - "primaryKey": true, - "type": "varchar(191)" - } - }, - "compositePrimaryKeys": {}, - "foreignKeys": {}, - "indexes": {}, - "isRLSEnabled": false, - "name": "doctrine_migration_versions", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - }, - "public.followed_source": { - "checkConstraints": {}, - "columns": { - "created_at": { - "name": "created_at", - "notNull": true, - "primaryKey": false, - "type": "timestamp(0)" - }, - "follower_id": { - "name": "follower_id", - "notNull": true, - "primaryKey": false, - "type": "uuid" - }, - "id": { - "name": "id", - "notNull": true, - "primaryKey": true, - "type": "uuid" - }, - "source_id": { - "name": "source_id", - "notNull": true, - "primaryKey": false, - "type": "uuid" - } - }, - "compositePrimaryKeys": {}, - "foreignKeys": { - "fk_7a763a3e953c1c61": { - "columnsFrom": ["source_id"], - "columnsTo": ["id"], - "name": "fk_7a763a3e953c1c61", - "onDelete": "cascade", - "onUpdate": "no action", - "schemaTo": "public", - "tableFrom": "followed_source", - "tableTo": "source" - }, - "fk_7a763a3eac24f853": { - "columnsFrom": ["follower_id"], - "columnsTo": ["id"], - "name": "fk_7a763a3eac24f853", - "onDelete": "cascade", - "onUpdate": "no action", - "schemaTo": "public", - "tableFrom": "followed_source", - "tableTo": "user" - } - }, - "indexes": { - "idx_7a763a3e953c1c61": { - "columns": [ - { - "asc": true, - "expression": "source_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_7a763a3e953c1c61", - "with": {} - }, - "idx_7a763a3eac24f853": { - "columns": [ - { - "asc": true, - "expression": "follower_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_7a763a3eac24f853", - "with": {} - }, - "idx_followed_source_follower_created": { - "columns": [ - { - "asc": true, - "expression": "follower_id", - "isExpression": false, - "nulls": "last", - "opclass": "timestamp_ops" - }, - { - "asc": false, - "expression": "created_at", - "isExpression": false, - "nulls": "first", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_followed_source_follower_created", - "with": {} - } - }, - "isRLSEnabled": false, - "name": "followed_source", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - }, - "public.login_attempt": { - "checkConstraints": {}, - "columns": { - "created_at": { - "name": "created_at", - "notNull": true, - "primaryKey": false, - "type": "timestamp(0)" - }, - "id": { - "name": "id", - "notNull": true, - "primaryKey": true, - "type": "uuid" - }, - "user_id": { - "name": "user_id", - "notNull": true, - "primaryKey": false, - "type": "uuid" - } - }, - "compositePrimaryKeys": {}, - "foreignKeys": { - "fk_8c11c1ba76ed395": { - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "name": "fk_8c11c1ba76ed395", - "onDelete": "cascade", - "onUpdate": "no action", - "schemaTo": "public", - "tableFrom": "login_attempt", - "tableTo": "user" - } - }, - "indexes": { - "idx_8c11c1ba76ed395": { - "columns": [ - { - "asc": true, - "expression": "user_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_8c11c1ba76ed395", - "with": {} - }, - "idx_login_attempt_created_at": { - "columns": [ - { - "asc": false, - "expression": "created_at", - "isExpression": false, - "nulls": "first", - "opclass": "timestamp_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_login_attempt_created_at", - "with": {} - } - }, - "isRLSEnabled": false, - "name": "login_attempt", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - }, - "public.login_history": { - "checkConstraints": {}, - "columns": { - "created_at": { - "name": "created_at", - "notNull": true, - "primaryKey": false, - "type": "timestamp(0)" - }, - "device_client": { - "default": "NULL", - "name": "device_client", - "notNull": false, - "primaryKey": false, - "type": "varchar(255)" - }, - "device_device": { - "default": "NULL", - "name": "device_device", - "notNull": false, - "primaryKey": false, - "type": "varchar(255)" - }, - "device_is_bot": { - "default": false, - "name": "device_is_bot", - "notNull": true, - "primaryKey": false, - "type": "boolean" - }, - "device_operating_system": { - "default": "NULL", - "name": "device_operating_system", - "notNull": false, - "primaryKey": false, - "type": "varchar(255)" - }, - "id": { - "name": "id", - "notNull": true, - "primaryKey": true, - "type": "uuid" - }, - "ip_address": { - "name": "ip_address", - "notNull": false, - "primaryKey": false, - "type": "inet" - }, - "location_accuracy_radius": { - "name": "location_accuracy_radius", - "notNull": false, - "primaryKey": false, - "type": "integer" - }, - "location_latitude": { - "name": "location_latitude", - "notNull": false, - "primaryKey": false, - "type": "double precision" - }, - "location_longitude": { - "name": "location_longitude", - "notNull": false, - "primaryKey": false, - "type": "double precision" - }, - "location_time_zone": { - "default": "NULL", - "name": "location_time_zone", - "notNull": false, - "primaryKey": false, - "type": "varchar(255)" - }, - "user_id": { - "name": "user_id", - "notNull": true, - "primaryKey": false, - "type": "uuid" - } - }, - "compositePrimaryKeys": {}, - "foreignKeys": { - "fk_37976e36a76ed395": { - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "name": "fk_37976e36a76ed395", - "onDelete": "cascade", - "onUpdate": "no action", - "schemaTo": "public", - "tableFrom": "login_history", - "tableTo": "user" - } - }, - "indexes": { - "idx_37976e36a76ed395": { - "columns": [ - { - "asc": true, - "expression": "user_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_37976e36a76ed395", - "with": {} - }, - "idx_login_history_created_at": { - "columns": [ - { - "asc": true, - "expression": "user_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - }, - { - "asc": false, - "expression": "created_at", - "isExpression": false, - "nulls": "first", - "opclass": "timestamp_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_login_history_created_at", - "with": {} - }, - "idx_login_history_ip_address": { - "columns": [ - { - "asc": true, - "expression": "ip_address", - "isExpression": false, - "nulls": "last", - "opclass": "inet_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_login_history_ip_address", - "with": {} - } - }, - "isRLSEnabled": false, - "name": "login_history", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - }, - "public.refresh_tokens": { - "checkConstraints": {}, - "columns": { - "id": { - "name": "id", - "notNull": true, - "primaryKey": true, - "type": "integer" - }, - "refresh_token": { - "name": "refresh_token", - "notNull": true, - "primaryKey": false, - "type": "varchar(128)" - }, - "username": { - "name": "username", - "notNull": true, - "primaryKey": false, - "type": "varchar(255)" - }, - "valid": { - "name": "valid", - "notNull": true, - "primaryKey": false, - "type": "timestamp(0)" - } - }, - "compositePrimaryKeys": {}, - "foreignKeys": {}, - "indexes": { - "uniq_9bace7e1c74f2195": { - "columns": [ - { - "asc": true, - "expression": "refresh_token", - "isExpression": false, - "nulls": "last", - "opclass": "text_ops" - } - ], - "concurrently": false, - "isUnique": true, - "method": "btree", - "name": "uniq_9bace7e1c74f2195", - "with": {} - } - }, - "isRLSEnabled": false, - "name": "refresh_tokens", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - }, - "public.source": { - "checkConstraints": {}, - "columns": { - "bias": { - "default": "'neutral'", - "name": "bias", - "notNull": true, - "primaryKey": false, - "type": "varchar(30)" - }, - "description": { - "default": "NULL", - "name": "description", - "notNull": false, - "primaryKey": false, - "type": "varchar(1024)" - }, - "display_name": { - "default": "NULL", - "name": "display_name", - "notNull": false, - "primaryKey": false, - "type": "varchar(255)" - }, - "id": { - "name": "id", - "notNull": true, - "primaryKey": true, - "type": "uuid" - }, - "name": { - "name": "name", - "notNull": true, - "primaryKey": false, - "type": "varchar(255)" - }, - "reliability": { - "default": "'reliable'", - "name": "reliability", - "notNull": true, - "primaryKey": false, - "type": "varchar(30)" - }, - "transparency": { - "default": "'medium'", - "name": "transparency", - "notNull": true, - "primaryKey": false, - "type": "varchar(30)" - }, - "updated_at": { - "default": "NULL", - "name": "updated_at", - "notNull": false, - "primaryKey": false, - "type": "timestamp(0)" - }, - "url": { - "name": "url", - "notNull": true, - "primaryKey": false, - "type": "varchar(255)" - } - }, - "compositePrimaryKeys": {}, - "foreignKeys": {}, - "indexes": { - "unq_source_name": { - "columns": [ - { - "asc": true, - "expression": "lower((name)::text)", - "isExpression": true, - "nulls": "last", - "opclass": "text_ops" - } - ], - "concurrently": false, - "isUnique": true, - "method": "btree", - "name": "unq_source_name", - "with": {} - }, - "unq_source_url": { - "columns": [ - { - "asc": true, - "expression": "lower((url)::text)", - "isExpression": true, - "nulls": "last", - "opclass": "text_ops" - } - ], - "concurrently": false, - "isUnique": true, - "method": "btree", - "name": "unq_source_url", - "with": {} - } - }, - "isRLSEnabled": false, - "name": "source", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - }, - "public.user": { - "checkConstraints": { - "chk_user_roles_json": { - "name": "chk_user_roles_json", - "value": "jsonb_typeof(roles) = 'array'::text" - } - }, - "columns": { - "created_at": { - "name": "created_at", - "notNull": true, - "primaryKey": false, - "type": "timestamp(0)" - }, - "email": { - "name": "email", - "notNull": true, - "primaryKey": false, - "type": "varchar(255)" - }, - "id": { - "name": "id", - "notNull": true, - "primaryKey": true, - "type": "uuid" - }, - "is_confirmed": { - "default": false, - "name": "is_confirmed", - "notNull": true, - "primaryKey": false, - "type": "boolean" - }, - "is_locked": { - "default": false, - "name": "is_locked", - "notNull": true, - "primaryKey": false, - "type": "boolean" - }, - "name": { - "name": "name", - "notNull": true, - "primaryKey": false, - "type": "varchar(255)" - }, - "password": { - "name": "password", - "notNull": true, - "primaryKey": false, - "type": "varchar(512)" - }, - "roles": { - "name": "roles", - "notNull": true, - "primaryKey": false, - "type": "jsonb" - }, - "updated_at": { - "default": "NULL", - "name": "updated_at", - "notNull": false, - "primaryKey": false, - "type": "timestamp(0)" - } - }, - "compositePrimaryKeys": {}, - "foreignKeys": {}, - "indexes": { - "unq_user_email": { - "columns": [ - { - "asc": true, - "expression": "lower((email)::text)", - "isExpression": true, - "nulls": "last", - "opclass": "text_ops" - } - ], - "concurrently": false, - "isUnique": true, - "method": "btree", - "name": "unq_user_email", - "with": {} - } - }, - "isRLSEnabled": false, - "name": "user", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - }, - "public.verification_token": { - "checkConstraints": {}, - "columns": { - "created_at": { - "name": "created_at", - "notNull": true, - "primaryKey": false, - "type": "timestamp(0)" - }, - "id": { - "name": "id", - "notNull": true, - "primaryKey": true, - "type": "uuid" - }, - "purpose": { - "name": "purpose", - "notNull": true, - "primaryKey": false, - "type": "varchar(255)" - }, - "token": { - "default": "NULL", - "name": "token", - "notNull": false, - "primaryKey": false, - "type": "varchar(60)" - }, - "user_id": { - "name": "user_id", - "notNull": true, - "primaryKey": false, - "type": "uuid" - } - }, - "compositePrimaryKeys": {}, - "foreignKeys": { - "fk_c1cc006ba76ed395": { - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "name": "fk_c1cc006ba76ed395", - "onDelete": "cascade", - "onUpdate": "no action", - "schemaTo": "public", - "tableFrom": "verification_token", - "tableTo": "user" - } - }, - "indexes": { - "idx_c1cc006ba76ed395": { - "columns": [ - { - "asc": true, - "expression": "user_id", - "isExpression": false, - "nulls": "last", - "opclass": "uuid_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_c1cc006ba76ed395", - "with": {} - }, - "idx_verif_token_created_at": { - "columns": [ - { - "asc": false, - "expression": "created_at", - "isExpression": false, - "nulls": "first", - "opclass": "timestamp_ops" - } - ], - "concurrently": false, - "isUnique": false, - "method": "btree", - "name": "idx_verif_token_created_at", - "with": {} - }, - "unq_verif_user_purpose_token": { - "columns": [ - { - "asc": true, - "expression": "user_id", - "isExpression": false, - "nulls": "last", - "opclass": "text_ops" - }, - { - "asc": true, - "expression": "purpose", - "isExpression": false, - "nulls": "last", - "opclass": "text_ops" - } - ], - "concurrently": false, - "isUnique": true, - "method": "btree", - "name": "unq_verif_user_purpose_token", - "where": "(token IS NOT NULL)", - "with": {} - } - }, - "isRLSEnabled": false, - "name": "verification_token", - "policies": {}, - "schema": "", - "uniqueConstraints": {} - } - }, + "sequences": {}, + "tables": {}, "version": "7", "views": {} } diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..e1fbabe --- /dev/null +++ b/packages/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1279 @@ +{ + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "dialect": "postgresql", + "enums": { + "public.bias": { + "name": "bias", + "schema": "public", + "values": ["neutral", "slightly", "partisan", "extreme"] + }, + "public.reliability": { + "name": "reliability", + "schema": "public", + "values": ["trusted", "reliable", "average", "low_trust", "unreliable"] + }, + "public.sentiment": { + "name": "sentiment", + "schema": "public", + "values": ["positive", "neutral", "negative"] + }, + "public.token_purpose": { + "name": "token_purpose", + "schema": "public", + "values": ["confirm_account", "password_reset", "unlock_account", "delete_account"] + }, + "public.transparency": { + "name": "transparency", + "schema": "public", + "values": ["high", "medium", "low"] + } + }, + "id": "2b230826-2e33-4eb8-9d90-f5b15f260fc2", + "policies": {}, + "prevId": "27985f7b-ced7-4c97-8e08-40f939e7e28c", + "roles": {}, + "schemas": {}, + "sequences": {}, + "tables": { + "public.article": { + "checkConstraints": {}, + "columns": { + "body": { + "name": "body", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "categories": { + "name": "categories", + "notNull": false, + "primaryKey": false, + "type": "text[]" + }, + "crawled_at": { + "default": "now()", + "name": "crawled_at", + "notNull": true, + "primaryKey": false, + "type": "timestamp" + }, + "credibility": { + "name": "credibility", + "notNull": false, + "primaryKey": false, + "type": "jsonb" + }, + "excerpt": { + "generated": { + "as": "(\"left\"(body, 200) || '...'::text)", + "type": "stored" + }, + "name": "excerpt", + "notNull": false, + "primaryKey": false, + "type": "varchar(255)" + }, + "hash": { + "name": "hash", + "notNull": true, + "primaryKey": false, + "type": "varchar(32)" + }, + "id": { + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "uuid" + }, + "image": { + "generated": { + "as": "(metadata ->> 'image'::text)", + "type": "stored" + }, + "name": "image", + "notNull": false, + "primaryKey": false, + "type": "varchar(1024)" + }, + "link": { + "name": "link", + "notNull": true, + "primaryKey": false, + "type": "varchar(1024)" + }, + "metadata": { + "name": "metadata", + "notNull": false, + "primaryKey": false, + "type": "jsonb" + }, + "published_at": { + "name": "published_at", + "notNull": true, + "primaryKey": false, + "type": "timestamp" + }, + "reading_time": { + "default": 1, + "name": "reading_time", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "sentiment": { + "name": "sentiment", + "notNull": true, + "primaryKey": false, + "type": "sentiment", + "typeSchema": "public" + }, + "source_id": { + "name": "source_id", + "notNull": true, + "primaryKey": false, + "type": "uuid" + }, + "title": { + "name": "title", + "notNull": true, + "primaryKey": false, + "type": "varchar(1024)" + }, + "token_statistics": { + "name": "token_statistics", + "notNull": false, + "primaryKey": false, + "type": "jsonb" + }, + "tsv": { + "generated": { + "as": "(\n setweight(to_tsvector('french'::regconfig, COALESCE(title, '')::text), 'A'::\"char\")\n || setweight(to_tsvector('french'::regconfig, COALESCE(body, ''::text)), 'B'::\"char\")\n )", + "type": "stored" + }, + "name": "tsv", + "notNull": false, + "primaryKey": false, + "type": "tsvector" + }, + "updated_at": { + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "timestamp" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": { + "fk_article_source_id": { + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "name": "fk_article_source_id", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "article", + "tableTo": "source" + } + }, + "indexes": { + "gin_article_categories": { + "columns": [ + { + "asc": true, + "expression": "categories", + "isExpression": false, + "nulls": "last", + "opclass": "array_ops" + } + ], + "concurrently": false, + "isUnique": false, + "method": "gin", + "name": "gin_article_categories", + "with": {} + }, + "gin_article_link_trgm": { + "columns": [ + { + "asc": true, + "expression": "link", + "isExpression": false, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "concurrently": false, + "isUnique": false, + "method": "gin", + "name": "gin_article_link_trgm", + "with": {} + }, + "gin_article_title_trgm": { + "columns": [ + { + "asc": true, + "expression": "title", + "isExpression": false, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "concurrently": false, + "isUnique": false, + "method": "gin", + "name": "gin_article_title_trgm", + "with": {} + }, + "gin_article_tsv": { + "columns": [ + { + "asc": true, + "expression": "tsv", + "isExpression": false, + "nulls": "last", + "opclass": "tsvector_ops" + } + ], + "concurrently": false, + "isUnique": false, + "method": "gin", + "name": "gin_article_tsv", + "with": {} + }, + "idx_article_source_published_id": { + "columns": [ + { + "asc": true, + "expression": "source_id", + "isExpression": false, + "nulls": "last" + }, + { + "asc": false, + "expression": "published_at", + "isExpression": false, + "nulls": "first" + }, + { + "asc": false, + "expression": "id", + "isExpression": false, + "nulls": "first" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_article_source_published_id", + "with": {} + }, + "unq_article_hash": { + "columns": [ + { + "asc": true, + "expression": "hash", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": true, + "method": "btree", + "name": "unq_article_hash", + "with": {} + } + }, + "isRLSEnabled": false, + "name": "article", + "policies": {}, + "schema": "", + "uniqueConstraints": {} + }, + "public.bookmark": { + "checkConstraints": {}, + "columns": { + "created_at": { + "default": "now()", + "name": "created_at", + "notNull": true, + "primaryKey": false, + "type": "timestamp" + }, + "description": { + "name": "description", + "notNull": false, + "primaryKey": false, + "type": "varchar(512)" + }, + "id": { + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "uuid" + }, + "is_public": { + "default": false, + "name": "is_public", + "notNull": true, + "primaryKey": false, + "type": "boolean" + }, + "name": { + "name": "name", + "notNull": true, + "primaryKey": false, + "type": "varchar(255)" + }, + "updated_at": { + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "timestamp" + }, + "user_id": { + "name": "user_id", + "notNull": true, + "primaryKey": false, + "type": "uuid" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": { + "fk_bookmark_user_id": { + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "name": "fk_bookmark_user_id", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "bookmark", + "tableTo": "user" + } + }, + "indexes": { + "idx_bookmark_user_created": { + "columns": [ + { + "asc": true, + "expression": "user_id", + "isExpression": false, + "nulls": "last" + }, + { + "asc": false, + "expression": "created_at", + "isExpression": false, + "nulls": "first" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_bookmark_user_created", + "with": {} + }, + "unq_bookmark_user_name": { + "columns": [ + { + "asc": true, + "expression": "user_id", + "isExpression": false, + "nulls": "last" + }, + { + "asc": true, + "expression": "lower(\"name\")", + "isExpression": true, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": true, + "method": "btree", + "name": "unq_bookmark_user_name", + "with": {} + } + }, + "isRLSEnabled": false, + "name": "bookmark", + "policies": {}, + "schema": "", + "uniqueConstraints": {} + }, + "public.bookmark_article": { + "checkConstraints": {}, + "columns": { + "article_id": { + "name": "article_id", + "notNull": true, + "primaryKey": false, + "type": "uuid" + }, + "bookmark_id": { + "name": "bookmark_id", + "notNull": true, + "primaryKey": false, + "type": "uuid" + } + }, + "compositePrimaryKeys": { + "bookmark_article_pkey": { + "columns": ["bookmark_id", "article_id"], + "name": "bookmark_article_pkey" + } + }, + "foreignKeys": { + "fk_bookmark_article_article_id": { + "columnsFrom": ["article_id"], + "columnsTo": ["id"], + "name": "fk_bookmark_article_article_id", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "bookmark_article", + "tableTo": "article" + }, + "fk_bookmark_article_bookmark_id": { + "columnsFrom": ["bookmark_id"], + "columnsTo": ["id"], + "name": "fk_bookmark_article_bookmark_id", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "bookmark_article", + "tableTo": "bookmark" + } + }, + "indexes": { + "idx_bookmark_article_bookmark_id": { + "columns": [ + { + "asc": true, + "expression": "bookmark_id", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_bookmark_article_bookmark_id", + "with": {} + } + }, + "isRLSEnabled": false, + "name": "bookmark_article", + "policies": {}, + "schema": "", + "uniqueConstraints": {} + }, + "public.comment": { + "checkConstraints": {}, + "columns": { + "article_id": { + "name": "article_id", + "notNull": true, + "primaryKey": false, + "type": "uuid" + }, + "content": { + "name": "content", + "notNull": true, + "primaryKey": false, + "type": "varchar(512)" + }, + "created_at": { + "default": "now()", + "name": "created_at", + "notNull": true, + "primaryKey": false, + "type": "timestamp" + }, + "id": { + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "uuid" + }, + "is_spam": { + "default": false, + "name": "is_spam", + "notNull": true, + "primaryKey": false, + "type": "boolean" + }, + "sentiment": { + "name": "sentiment", + "notNull": true, + "primaryKey": false, + "type": "sentiment", + "typeSchema": "public" + }, + "user_id": { + "name": "user_id", + "notNull": true, + "primaryKey": false, + "type": "uuid" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": { + "fk_comment_article_id": { + "columnsFrom": ["article_id"], + "columnsTo": ["id"], + "name": "fk_comment_article_id", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "comment", + "tableTo": "article" + }, + "fk_comment_user_id": { + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "name": "fk_comment_user_id", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "comment", + "tableTo": "user" + } + }, + "indexes": { + "idx_comment_article_created": { + "columns": [ + { + "asc": true, + "expression": "article_id", + "isExpression": false, + "nulls": "last" + }, + { + "asc": false, + "expression": "created_at", + "isExpression": false, + "nulls": "first" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_comment_article_created", + "with": {} + }, + "idx_comment_article_id": { + "columns": [ + { + "asc": true, + "expression": "article_id", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_comment_article_id", + "with": {} + }, + "idx_comment_user_id": { + "columns": [ + { + "asc": true, + "expression": "user_id", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_comment_user_id", + "with": {} + } + }, + "isRLSEnabled": false, + "name": "comment", + "policies": {}, + "schema": "", + "uniqueConstraints": {} + }, + "public.followed_source": { + "checkConstraints": {}, + "columns": { + "created_at": { + "default": "now()", + "name": "created_at", + "notNull": true, + "primaryKey": false, + "type": "timestamp" + }, + "follower_id": { + "name": "follower_id", + "notNull": true, + "primaryKey": false, + "type": "uuid" + }, + "id": { + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "uuid" + }, + "source_id": { + "name": "source_id", + "notNull": true, + "primaryKey": false, + "type": "uuid" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": { + "fk_followed_source_follower_id": { + "columnsFrom": ["follower_id"], + "columnsTo": ["id"], + "name": "fk_followed_source_follower_id", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "followed_source", + "tableTo": "user" + }, + "fk_followed_source_source_id": { + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "name": "fk_followed_source_source_id", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "followed_source", + "tableTo": "source" + } + }, + "indexes": { + "idx_followed_source_follower_created": { + "columns": [ + { + "asc": true, + "expression": "follower_id", + "isExpression": false, + "nulls": "last" + }, + { + "asc": false, + "expression": "created_at", + "isExpression": false, + "nulls": "first" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_followed_source_follower_created", + "with": {} + }, + "idx_followed_source_follower_id": { + "columns": [ + { + "asc": true, + "expression": "follower_id", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_followed_source_follower_id", + "with": {} + }, + "idx_followed_source_source_id": { + "columns": [ + { + "asc": true, + "expression": "source_id", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_followed_source_source_id", + "with": {} + }, + "unq_followed_source_user_source": { + "columns": [ + { + "asc": true, + "expression": "follower_id", + "isExpression": false, + "nulls": "last" + }, + { + "asc": true, + "expression": "source_id", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": true, + "method": "btree", + "name": "unq_followed_source_user_source", + "with": {} + } + }, + "isRLSEnabled": false, + "name": "followed_source", + "policies": {}, + "schema": "", + "uniqueConstraints": {} + }, + "public.login_attempt": { + "checkConstraints": {}, + "columns": { + "created_at": { + "default": "now()", + "name": "created_at", + "notNull": true, + "primaryKey": false, + "type": "timestamp" + }, + "id": { + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "uuid" + }, + "user_id": { + "name": "user_id", + "notNull": true, + "primaryKey": false, + "type": "uuid" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": { + "fk_login_attempt_user_id": { + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "name": "fk_login_attempt_user_id", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "login_attempt", + "tableTo": "user" + } + }, + "indexes": { + "idx_login_attempt_user_created": { + "columns": [ + { + "asc": true, + "expression": "user_id", + "isExpression": false, + "nulls": "last" + }, + { + "asc": false, + "expression": "created_at", + "isExpression": false, + "nulls": "first" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_login_attempt_user_created", + "with": {} + } + }, + "isRLSEnabled": false, + "name": "login_attempt", + "policies": {}, + "schema": "", + "uniqueConstraints": {} + }, + "public.login_history": { + "checkConstraints": {}, + "columns": { + "created_at": { + "default": "now()", + "name": "created_at", + "notNull": true, + "primaryKey": false, + "type": "timestamp" + }, + "device": { + "name": "device", + "notNull": false, + "primaryKey": false, + "type": "jsonb" + }, + "id": { + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "uuid" + }, + "ip_address": { + "name": "ip_address", + "notNull": false, + "primaryKey": false, + "type": "inet" + }, + "location": { + "name": "location", + "notNull": false, + "primaryKey": false, + "type": "jsonb" + }, + "user_id": { + "name": "user_id", + "notNull": true, + "primaryKey": false, + "type": "uuid" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": { + "fk_login_history_user_id": { + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "name": "fk_login_history_user_id", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "login_history", + "tableTo": "user" + } + }, + "indexes": { + "idx_login_history_ip_address": { + "columns": [ + { + "asc": true, + "expression": "ip_address", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_login_history_ip_address", + "with": {} + }, + "idx_login_history_user_created": { + "columns": [ + { + "asc": true, + "expression": "user_id", + "isExpression": false, + "nulls": "last" + }, + { + "asc": false, + "expression": "created_at", + "isExpression": false, + "nulls": "first" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_login_history_user_created", + "with": {} + } + }, + "isRLSEnabled": false, + "name": "login_history", + "policies": {}, + "schema": "", + "uniqueConstraints": {} + }, + "public.refresh_token": { + "checkConstraints": {}, + "columns": { + "id": { + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "uuid" + }, + "token": { + "name": "token", + "notNull": true, + "primaryKey": false, + "type": "varchar(128)" + }, + "username": { + "name": "username", + "notNull": true, + "primaryKey": false, + "type": "varchar(255)" + }, + "valid": { + "name": "valid", + "notNull": true, + "primaryKey": false, + "type": "timestamp" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": {}, + "indexes": { + "idx_refresh_token_username": { + "columns": [ + { + "asc": true, + "expression": "lower(\"username\")", + "isExpression": true, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_refresh_token_username", + "with": {} + }, + "idx_refresh_token_valid": { + "columns": [ + { + "asc": true, + "expression": "valid", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_refresh_token_valid", + "with": {} + }, + "uniq_refresh_token_token": { + "columns": [ + { + "asc": true, + "expression": "token", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": true, + "method": "btree", + "name": "uniq_refresh_token_token", + "with": {} + } + }, + "isRLSEnabled": false, + "name": "refresh_token", + "policies": {}, + "schema": "", + "uniqueConstraints": {} + }, + "public.source": { + "checkConstraints": {}, + "columns": { + "credibility": { + "name": "credibility", + "notNull": false, + "primaryKey": false, + "type": "jsonb" + }, + "description": { + "name": "description", + "notNull": false, + "primaryKey": false, + "type": "varchar(1024)" + }, + "display_name": { + "name": "display_name", + "notNull": false, + "primaryKey": false, + "type": "varchar(255)" + }, + "id": { + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "uuid" + }, + "name": { + "name": "name", + "notNull": true, + "primaryKey": false, + "type": "varchar(255)" + }, + "updated_at": { + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "timestamp" + }, + "url": { + "name": "url", + "notNull": true, + "primaryKey": false, + "type": "varchar(255)" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": {}, + "indexes": { + "unq_source_name": { + "columns": [ + { + "asc": true, + "expression": "lower((name)::text)", + "isExpression": true, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": true, + "method": "btree", + "name": "unq_source_name", + "with": {} + }, + "unq_source_url": { + "columns": [ + { + "asc": true, + "expression": "lower((url)::text)", + "isExpression": true, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": true, + "method": "btree", + "name": "unq_source_url", + "with": {} + } + }, + "isRLSEnabled": false, + "name": "source", + "policies": {}, + "schema": "", + "uniqueConstraints": {} + }, + "public.user": { + "checkConstraints": {}, + "columns": { + "created_at": { + "default": "now()", + "name": "created_at", + "notNull": true, + "primaryKey": false, + "type": "timestamp" + }, + "email": { + "name": "email", + "notNull": true, + "primaryKey": false, + "type": "varchar(255)" + }, + "id": { + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "uuid" + }, + "is_confirmed": { + "default": false, + "name": "is_confirmed", + "notNull": true, + "primaryKey": false, + "type": "boolean" + }, + "is_locked": { + "default": false, + "name": "is_locked", + "notNull": true, + "primaryKey": false, + "type": "boolean" + }, + "name": { + "name": "name", + "notNull": true, + "primaryKey": false, + "type": "varchar(255)" + }, + "password": { + "name": "password", + "notNull": true, + "primaryKey": false, + "type": "varchar(512)" + }, + "roles": { + "default": "'{\"ROLE_USER\"}'", + "name": "roles", + "notNull": true, + "primaryKey": false, + "type": "varchar(255)[]" + }, + "updated_at": { + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "timestamp" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": {}, + "indexes": { + "idx_user_created_at": { + "columns": [ + { + "asc": true, + "expression": "created_at", + "isExpression": true, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_user_created_at", + "with": {} + }, + "unq_user_email": { + "columns": [ + { + "asc": true, + "expression": "lower((email)::text)", + "isExpression": true, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": true, + "method": "btree", + "name": "unq_user_email", + "with": {} + } + }, + "isRLSEnabled": false, + "name": "user", + "policies": {}, + "schema": "", + "uniqueConstraints": {} + }, + "public.verification_token": { + "checkConstraints": {}, + "columns": { + "created_at": { + "default": "now()", + "name": "created_at", + "notNull": true, + "primaryKey": false, + "type": "timestamp" + }, + "id": { + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "uuid" + }, + "purpose": { + "name": "purpose", + "notNull": true, + "primaryKey": false, + "type": "token_purpose", + "typeSchema": "public" + }, + "token": { + "name": "token", + "notNull": false, + "primaryKey": false, + "type": "varchar(60)" + }, + "user_id": { + "name": "user_id", + "notNull": true, + "primaryKey": false, + "type": "uuid" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": { + "fk_verification_token_user_id": { + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "name": "fk_verification_token_user_id", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "verification_token", + "tableTo": "user" + } + }, + "indexes": { + "idx_verif_token_created_at": { + "columns": [ + { + "asc": false, + "expression": "created_at", + "isExpression": false, + "nulls": "first" + } + ], + "concurrently": false, + "isUnique": false, + "method": "btree", + "name": "idx_verif_token_created_at", + "with": {} + }, + "unq_verif_token_token": { + "columns": [ + { + "asc": true, + "expression": "token", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": true, + "method": "btree", + "name": "unq_verif_token_token", + "where": "\"verification_token\".\"token\" IS NOT NULL", + "with": {} + }, + "unq_verif_user_purpose_token": { + "columns": [ + { + "asc": true, + "expression": "user_id", + "isExpression": false, + "nulls": "last" + }, + { + "asc": true, + "expression": "purpose", + "isExpression": false, + "nulls": "last" + }, + { + "asc": true, + "expression": "token", + "isExpression": false, + "nulls": "last" + } + ], + "concurrently": false, + "isUnique": true, + "method": "btree", + "name": "unq_verif_user_purpose_token", + "where": "\"verification_token\".\"token\" IS NOT NULL", + "with": {} + } + }, + "isRLSEnabled": false, + "name": "verification_token", + "policies": {}, + "schema": "", + "uniqueConstraints": {} + } + }, + "version": "7", + "views": {} +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 0b385a3..b49dac3 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -4,9 +4,16 @@ { "breakpoints": true, "idx": 0, - "tag": "0000_aromatic_dorian_gray", + "tag": "0000_setup", "version": "7", - "when": 1762691204645 + "when": 1762775141000 + }, + { + "breakpoints": true, + "idx": 1, + "tag": "0001_init", + "version": "7", + "when": 1762775267679 } ], "version": "7" diff --git a/packages/db/package.json b/packages/db/package.json index 099a913..b896642 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,10 +1,14 @@ { "dependencies": { + "@basango/encryption": "workspace:*", "@basango/logger": "workspace:*", "@date-fns/utc": "^2.1.1", "drizzle-orm": "^0.44.7", + "mysql2": "^3.15.3", "pg": "^8.16.3", - "snakecase-keys": "^9.0.2" + "snakecase-keys": "^9.0.2", + "tiktoken": "^1.0.22", + "uuid": "^13.0.0" }, "devDependencies": { "@types/pg": "^8.15.6", @@ -13,6 +17,7 @@ "exports": { ".": "./src/index.ts", "./client": "./src/client.ts", + "./importer": "./src/importer/index.ts", "./queries": "./src/queries/index.ts", "./schema": "./src/schema.ts", "./utils": "./src/utils/index.ts" @@ -21,6 +26,7 @@ "private": true, "scripts": { "clean": "rm -rf .turbo node_modules", + "sync:import": "bun ./src/importer/import.ts", "typecheck": "tsc --noEmit" } } diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts new file mode 100644 index 0000000..191c4c7 --- /dev/null +++ b/packages/db/src/errors.ts @@ -0,0 +1,6 @@ +export class NotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "NotFoundError"; + } +} diff --git a/packages/db/src/importer/engine.ts b/packages/db/src/importer/engine.ts new file mode 100644 index 0000000..33e448f --- /dev/null +++ b/packages/db/src/importer/engine.ts @@ -0,0 +1,468 @@ +import { RowDataPacket } from "mysql2/promise"; +import { Pool, PoolClient } from "pg"; + +import { computeReadingTime, computeTokenStatistics } from "@/utils/computed"; + +type SourceOptions = { + host: string; + user: string; + password: string; + database: string; +}; + +type TargetOptions = { + database: string; + batchSize?: number; + pageSize?: number; + ignoreColumns?: Record; +}; + +const DEFAULT_IGNORE: Record = { + article: ["tsv", "image", "excerpt", "bias", "reliability", "transparency"], + source: ["bias", "reliability", "transparency"], +}; + +/** + * Engine + * + * Coordinates copying rows from a MySQL source into a PostgreSQL target in a + * controlled, transactional, batched manner. + * + * Responsibilities: + * - Establish and manage a connection pool to the target PostgreSQL database. + * - Stream rows from a MySQL source (via a temporary pool) using pagination. + * - Transform row values to match target expectations (UUID normalization, + * timestamp fallback, array parsing for categories/roles, computed JSON + * credibility, etc.). + * - Filter out ignored columns based on a configurable ignore map. + * - Insert rows into the target in configurable batch sizes with transactional + * commits every batch to limit long-running transactions. + * - Provide a safe reset operation that truncates the target table and manages + * session replication role toggling for Postgres. + * + * @param sourceOptions - connection and authentication options for the MySQL + * source (database, host, user, password, etc.). + * @param targetOptions - configuration for the Postgres target including + * connection string (database), optional pageSize, batchSize and per-table + * ignoreColumns map. + */ +export class Engine { + private readonly target: Pool; + private readonly ignore: Record; + private readonly pageSize: number; + private readonly batchSize: number; + + constructor( + private readonly sourceOptions: SourceOptions, + private readonly targetOptions: TargetOptions, + ) { + this.target = new Pool({ + allowExitOnIdle: true, + connectionString: this.targetOptions.database, + max: 8, + }); + this.ignore = { ...DEFAULT_IGNORE, ...(this.targetOptions.ignoreColumns ?? {}) }; + this.pageSize = this.targetOptions.pageSize ?? 10_000; + this.batchSize = Math.max(1, this.targetOptions.batchSize ?? 1000); + console.log( + `Engine initialized with pageSize=${this.pageSize} and batchSize=${this.batchSize}`, + ); + } + + async close() { + await this.target.end(); + } + + async import(table: string): Promise { + await this.reset(table); + return await this.paste(table, this.copy(table)); + } + + private async *copy(table: string): AsyncGenerator> { + const mysql = await import("mysql2/promise"); + + const source = mysql.createPool({ + database: this.sourceOptions.database, + host: this.sourceOptions.host, + idleTimeout: 180_000_000, + password: this.sourceOptions.password, + port: 3306, + rowsAsArray: false, + user: this.sourceOptions.user, + }); + + let offset = 0; + const size = this.pageSize; + try { + while (true) { + const [rows] = await source.query( + `SELECT * FROM \`${this.escapeBacktick(table)}\` LIMIT ? OFFSET ?`, + [size, offset], + ); + + if (!rows || rows.length === 0) break; + + for (const row of rows) { + yield row as Record; + } + + offset += rows.length; + if (rows.length < size) break; + } + } finally { + try { + await source.end(); + } catch {} + } + } + + private async paste( + table: string, + rows: AsyncGenerator>, + ): Promise { + const target = await this.target.connect(); + let total = 0; + let inBatch = 0; + let columns: string[] | null = null; + let insertSql = ""; + + const ignored = this.ignoredColumnsFor(table); + const ignoredSet = new Set(ignored); + + try { + for await (let row of rows) { + if (!columns) { + row = this.transformRowForTarget(table, row); + // Filter ignored columns and build column order + columns = Object.keys(row).filter((c) => !ignoredSet.has(c)); + + // If article target has credibility but source not, include computed credibility + if ( + (this.normalizedName(table) === "article" && !columns.includes("credibility")) || + (this.normalizedName(table) === "source" && !columns.includes("credibility")) + ) { + columns.push("credibility"); + } + + if (this.normalizedName(table) === "article" && !columns.includes("token_statistics")) { + columns.push("token_statistics"); + } + + const colsSql = columns.map((c) => this.quote(c)).join(", "); + const placeholders = columns.map((_, i) => `$${i + 1}`).join(", "); + insertSql = `INSERT INTO ${this.quote(table)} (${colsSql}) VALUES (${placeholders})`; + + await target.query("BEGIN"); + } + + // Row transform and params in column order + const transformed = this.transformRowForTarget(table, row); + const params = columns!.map((c) => this.valueForColumn(c, transformed)); + + try { + await target.query(insertSql, params); + } catch (err: unknown) { + const msg = String((err as Error)?.message ?? ""); + if (msg.includes("invalid input syntax for type timestamp")) { + // Fallback: coerce all *_at params to now() and retry once + const fixed = columns!.map((c, i) => (c.endsWith("_at") ? new Date() : params[i])); + await target.query(insertSql, fixed); + } else { + throw err; + } + } + total++; + inBatch++; + + if (inBatch >= this.batchSize) { + await target.query("COMMIT"); + inBatch = 0; + await target.query("BEGIN"); + console.log(`Imported ${total} records into ${table} so far...`); + } + } + + if (inBatch > 0) { + await target.query("COMMIT"); + } + } catch (e) { + await safeRollback(target); + throw e; + } finally { + target.release(); + } + + return total; + } + + private normalizedName(table: string): string { + return table.replaceAll('"', "").replaceAll("`", "").toLowerCase(); + } + + private ignoredColumnsFor(table: string): string[] { + return this.ignore[this.normalizedName(table)] ?? []; + } + + private async reset(table: string) { + const client = await this.target.connect(); + try { + await client.query("BEGIN"); + await client.query("SET session_replication_role = 'replica'"); + await client.query(`TRUNCATE TABLE ${this.quote(table)} RESTART IDENTITY CASCADE`); + await client.query("SET session_replication_role = 'origin'"); + await client.query("COMMIT"); + console.log(`Reset completed for table ${table}`); + } catch (e) { + await safeRollback(client); + throw e; + } finally { + client.release(); + } + } + + private transformRowForTarget(table: string, row: Record) { + const t = this.normalizedName(table); + const clone: Record = { ...row }; + + // Normalize UUIDs and timestamps and categories + for (const [key, val] of Object.entries(clone)) { + if (val == null) continue; + + if (key === "id" || key.endsWith("_id")) { + clone[key] = this.normalizeUuidValue(val); + continue; + } + + // Robust timestamp normalization for *_at columns + if (key.endsWith("_at")) { + clone[key] = this.normalizeTimestampValue(val); + continue; + } + + if (key === "categories") { + if (Array.isArray(val)) { + clone[key] = val; + } else if (typeof val === "string") { + const raw = val.trim(); + // Try JSON first + if (raw.startsWith("[") && raw.endsWith("]")) { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + clone[key] = parsed; + continue; + } + } catch {} + } + + const parts = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + clone[key] = parts.length ? parts : null; + } + } + + if (t === "article" && key === "token_statistics") { + clone[key] = computeTokenStatistics({ + body: String(clone.body ?? ""), + categories: Array.isArray(clone.categories) ? clone.categories : [], + title: String(clone.title ?? ""), + }); + } + + if (t === "article" && key === "reading_time") { + clone[key] = Math.max(1, computeReadingTime(String(clone.body ?? ""))); + } + + if (key === "roles") { + if (Array.isArray(val)) { + clone[key] = val; + } else if (typeof val === "string") { + const raw = val.trim(); + + // If the value is a JSON array string like '["ROLE_USER","ROLE_ADMIN"]', parse it. + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + clone[key] = parsed; + continue; + } + } catch { + // not JSON, fall back to CSV-like parsing below + } + + // Remove surrounding brackets/quotes then split by comma and strip quotes/space + const parts = raw + .replace(/^\[|\]$/g, "") + .split(",") + .map((s) => s.replace(/^["']|["']$/g, "").trim()) + .filter(Boolean); + + clone[key] = parts.length ? parts : ["ROLE_USER"]; + } + } + } + + // compute credibility JSON if bias/reliability/transparency present + if (t === "article" || t === "source") { + const bias = clone.bias ?? null; + const reliability = clone.reliability ?? null; + const transparency = clone.transparency ?? null; + if (bias || reliability || transparency) { + clone.credibility = { + bias, + reliability, + transparency, + }; + } + } + + // Ensure article token_statistics exists (computed on the fly) + if ( + t === "article" && + (clone.token_statistics == null || typeof clone.token_statistics !== "object") + ) { + clone.token_statistics = computeTokenStatistics({ + body: String(clone.body ?? ""), + categories: Array.isArray(clone.categories) ? (clone.categories as string[]) : [], + title: String(clone.title ?? ""), + }); + } + + return clone; + } + + private valueForColumn(col: string, row: Record) { + const v = row[col]; + // Pass Date objects directly to pg for timestamp columns + if (col.endsWith("_at") && v instanceof Date) { + return v; + } + if (col === "credibility" && v && typeof v === "object") { + return JSON.stringify(v); + } + if (col === "token_statistics" && v && typeof v === "object") { + return JSON.stringify(v); + } + if (col === "device" && v && typeof v === "object") { + return JSON.stringify(v); + } + if (col === "location" && v && typeof v === "object") { + return JSON.stringify(v); + } + if (col === "roles" && v) { + return JSON.stringify(v); + } + if (col === "metadata" && v && typeof v === "object") { + return JSON.stringify(v); + } + return v ?? null; + } + + private normalizeUuidValue(value: unknown): string { + if (Buffer.isBuffer(value)) { + return bufferToUuid(value); + } + if (typeof value === "string") { + // Already a UUID string or hex; try to format 32-hex into canonical form + const hex = value.replace(/-/g, "").toLowerCase(); + if (/^[0-9a-f]{32}$/.test(hex)) { + return ( + hex.slice(0, 8) + + "-" + + hex.slice(8, 12) + + "-" + + hex.slice(12, 16) + + "-" + + hex.slice(16, 20) + + "-" + + hex.slice(20) + ); + } + return value; + } + return String(value); + } + + private normalizeTimestampValue(value: unknown): Date { + // If it's already a Date, ensure it's valid + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? new Date() : value; + } + + // Strings: handle common invalid patterns and attempt safe parsing + if (typeof value === "string") { + const raw = value.trim(); + if ( + !raw || + /0000-00-00/.test(raw) || + /NaN/.test(raw) || + raw.toLowerCase() === "invalid date" + ) { + return new Date(); + } + + // Normalize MySQL-like 'YYYY-MM-DD HH:MM:SS[.ffffff]' to ISO + let s = raw.replace(" ", "T"); + // Reduce microseconds to milliseconds (3 digits) if present + s = s.replace(/\.(\d{3})\d+$/, ".$1"); + // Append Z if there is no timezone info + if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) s += "Z"; + + const d = new Date(s); + if (!Number.isNaN(d.getTime())) return d; + + // Try numeric string as epoch seconds/millis + const n = Number(raw); + if (Number.isFinite(n)) { + const ms = n > 1e12 ? n : n * 1000; + const d2 = new Date(ms); + if (!Number.isNaN(d2.getTime())) return d2; + } + + return new Date(); + } + + // Numbers: treat as epoch seconds/millis + if (typeof value === "number" && Number.isFinite(value)) { + const ms = value > 1e12 ? value : value * 1000; + const d = new Date(ms); + return Number.isNaN(d.getTime()) ? new Date() : d; + } + + // Fallback: now + return new Date(); + } + + private quote(id: string) { + const norm = this.normalizedName(id); + return `"${norm.replaceAll('"', '""')}"`; + } + + private escapeBacktick(id: string) { + return id.replaceAll("`", "``"); + } +} + +function bufferToUuid(buf: Buffer): string { + if (buf.length !== 16) return buf.toString("hex"); + const hex = buf.toString("hex"); + return ( + hex.slice(0, 8) + + "-" + + hex.slice(8, 12) + + "-" + + hex.slice(12, 16) + + "-" + + hex.slice(16, 20) + + "-" + + hex.slice(20) + ); +} + +async function safeRollback(client: PoolClient) { + try { + await client.query("ROLLBACK"); + } catch {} +} diff --git a/packages/db/src/importer/import.ts b/packages/db/src/importer/import.ts new file mode 100644 index 0000000..0d7d5d9 --- /dev/null +++ b/packages/db/src/importer/import.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env bun +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; + +import { createEnvAccessor } from "@devscast/config"; + +import { Engine } from "@/importer"; + +const env = createEnvAccessor([ + "BASANGO_SOURCE_DATABASE_HOST", + "BASANGO_SOURCE_DATABASE_USER", + "BASANGO_SOURCE_DATABASE_PASS", + "BASANGO_SOURCE_DATABASE_NAME", + "BASANGO_DATABASE_URL", +]); + +async function promptConfirm(question: string, def = false) { + const rl = createInterface({ input, output }); + const suffix = def ? "[Y/n]" : "[y/N]"; + const answer = await rl.question(`${question} ${suffix} `); + rl.close(); + const v = String(answer || "") + .trim() + .toLowerCase(); + if (v === "y" || v === "yes") return true; + if (v === "n" || v === "no") return false; + return def; +} + +async function main() { + const ok = await promptConfirm("Do you want to continue?", false); + if (!ok) { + console.warn("Process aborted"); + process.exit(1); + } + + const engine = new Engine( + { + database: env("BASANGO_SOURCE_DATABASE_NAME"), + host: env("BASANGO_SOURCE_DATABASE_HOST"), + password: env("BASANGO_SOURCE_DATABASE_PASS"), + user: env("BASANGO_SOURCE_DATABASE_USER"), + }, + { + database: env("BASANGO_DATABASE_URL"), + }, + ); + + try { + const tables = process.argv.slice(2); + if (tables.length === 0) tables.push("user", "source", "article"); + for (const t of tables) { + const count = await engine.import(t); + console.log(`Imported ${count} records into ${t} table.`); + } + console.log("Import completed successfully"); + } finally { + await engine.close(); + } +} + +main().catch((err) => { + console.error(err?.message ?? err); + process.exit(1); +}); diff --git a/packages/db/src/importer/index.ts b/packages/db/src/importer/index.ts new file mode 100644 index 0000000..bd104fd --- /dev/null +++ b/packages/db/src/importer/index.ts @@ -0,0 +1,2 @@ +export * from "./engine"; +export * from "./import"; diff --git a/packages/db/src/queries/articles.ts b/packages/db/src/queries/articles.ts index e69de29..a7648ee 100644 --- a/packages/db/src/queries/articles.ts +++ b/packages/db/src/queries/articles.ts @@ -0,0 +1,65 @@ +import { md5 } from "@basango/encryption"; +import { eq } from "drizzle-orm"; +import { v7 as uuidV7 } from "uuid"; + +import { Database } from "@/client"; +import { ArticleMetadata, Sentiment, TokenStatistics, article } from "@/schema"; +import { computeReadingTime, computeTokenStatistics } from "@/utils/computed"; + +import { getSourceIdByName } from "./sources"; + +export type CreateArticleParams = { + title: string; + body: string; + categories: string[]; + link: string; + sourceId: string; + publishedAt: Date; + sentiment?: Sentiment; + tokenStatistics?: TokenStatistics; + readingTime?: number; + metadata?: ArticleMetadata; +}; + +export async function createArticle(db: Database, params: CreateArticleParams) { + const data = { + ...params, + hash: md5(params.link), + readingTime: computeReadingTime(params.body), + sentiment: "neutral" as Sentiment, + sourceId: await getSourceIdByName(db, params.sourceId), + tokenStatistics: computeTokenStatistics({ + body: params.body, + categories: params.categories, + title: params.title, + }), + }; + + const duplicated = await getArticleByHash(db, data.hash); + if (duplicated !== undefined) { + return { + id: duplicated.id, + sourceId: duplicated.sourceId, + }; + } + + const [result] = await db + .insert(article) + .values({ id: uuidV7(), ...data }) + .returning({ + id: article.id, + sourceId: article.sourceId, + }); + + if (result === undefined) { + throw new Error("Failed to create article"); + } + + return result; +} + +export async function getArticleByHash(db: Database, hash: string) { + return db.query.article.findFirst({ + where: eq(article.hash, hash), + }); +} diff --git a/packages/db/src/queries/sources.ts b/packages/db/src/queries/sources.ts index e69de29..d662be4 100644 --- a/packages/db/src/queries/sources.ts +++ b/packages/db/src/queries/sources.ts @@ -0,0 +1,65 @@ +import { eq } from "drizzle-orm"; +import { v7 as uuidV7 } from "uuid"; + +import { Database } from "@/client"; +import { NotFoundError } from "@/errors"; +import { Credibility, source } from "@/schema"; + +export type CreateSourceParams = { + name: string; + url: string; + displayName?: string; + description?: string; + credibility: Credibility; + updatedAt?: Date; +}; + +export async function createSource(db: Database, params: CreateSourceParams) { + const [result] = await db + .insert(source) + .values({ id: uuidV7(), ...params }) + .returning(); + + return result; +} + +export type DeleteSourceParams = { + id: string; +}; + +export async function deleteSource(db: Database, params: DeleteSourceParams) { + const [result] = await db.delete(source).where(eq(source.id, params.id)).returning(); + + return result; +} + +export async function getSourceByName(db: Database, name: string) { + return db.query.source.findFirst({ + where: eq(source.name, name), + }); +} + +export async function getSourceIdByName(db: Database, name: string): Promise { + const result = await db.query.source.findFirst({ + columns: { + id: true, + }, + where: eq(source.name, name), + }); + + if (!result) { + throw new NotFoundError(`Source with name "${name}" not found`); + } + + return result.id; +} + +export type GetSourceByIdParams = { + id: string; +}; + +export async function getSourceById(db: Database, params: GetSourceByIdParams) { + return db.query.source.findFirst({ + where: eq(source.id, params.id), + }); +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index c2b23df..15f44ad 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,15 +1,14 @@ import { relations, sql } from "drizzle-orm"; +import { check } from "drizzle-orm/gel-core"; import { boolean, - check, customType, - doublePrecision, foreignKey, index, inet, integer, jsonb, - pgSequence, + pgEnum, pgTable, primaryKey, text, @@ -19,242 +18,181 @@ import { varchar, } from "drizzle-orm/pg-core"; -const tsvector = customType<{ data: string; driverData: string }>({ +/* -------------------------------------------------------------------------- */ +/* Types */ +/* -------------------------------------------------------------------------- */ + +export const tsvector = customType<{ data: string; driverData: string }>({ dataType() { return "tsvector"; }, }); -export const refreshTokensIdSeq = pgSequence("refresh_tokens_id_seq", { - cache: "1", - cycle: false, - increment: "1", - maxValue: "9223372036854775807", - minValue: "1", - startWith: "1", -}); +export const customJsonType = () => + customType<{ data: T }>({ + dataType() { + return "jsonb"; + }, + fromDriver(value) { + return value as T; + }, + toDriver(value) { + return value; // JSONB → just pass the object + }, + }); -// legacy table for doctrine migrations -export const doctrineMigrationVersions = pgTable("doctrine_migration_versions", { - executedAt: timestamp("executed_at", { mode: "string" }).default(sql`NULL`), - executionTime: integer("execution_time"), - version: varchar({ length: 191 }).primaryKey().notNull(), -}); +export const biasEnum = pgEnum("bias", ["neutral", "slightly", "partisan", "extreme"]); +export const reliabilityEnum = pgEnum("reliability", [ + "trusted", + "reliable", + "average", + "low_trust", + "unreliable", +]); +export const sentimentEnum = pgEnum("sentiment", ["positive", "neutral", "negative"]); +export const transparencyEnum = pgEnum("transparency", ["high", "medium", "low"]); +export const tokenPurposeEnum = pgEnum("token_purpose", [ + "confirm_account", + "password_reset", + "unlock_account", + "delete_account", +]); -export const bookmark = pgTable( - "bookmark", +export type EmailAddress = string; +export type Link = string; +export type ReadingTime = number; + +export type Role = "ROLE_USER" | "ROLE_ADMIN"; +export type Roles = Role[]; + +export type Bias = (typeof biasEnum.enumValues)[number]; +export type Reliability = (typeof reliabilityEnum.enumValues)[number]; +export type Sentiment = (typeof sentimentEnum.enumValues)[number]; +export type Transparency = (typeof transparencyEnum.enumValues)[number]; +export type TokenPurpose = (typeof tokenPurposeEnum.enumValues)[number]; + +export type Credibility = { + bias: Bias; + reliability: Reliability; + transparency: Transparency; +}; + +export type TokenStatistics = { + title: number; + body: number; + categories: number; + excerpt: number; + total: number; +}; + +export type Device = { + operatingSystem?: string; + client?: string; + device?: string; + isBot: boolean; +}; + +export type GeoLocation = { + country?: string; + city?: string; + timeZone?: string; + longitude?: number; + latitude?: number; + accuracyRadius?: number; +}; + +export type ClientProfile = { + userIp?: string; + userAgent?: string; + hints: unknown[]; +}; + +export type ArticleMetadata = { + title?: string; + description?: string; + image?: string; +}; + +export type DateRange = { + start: number; // unix timestamp (seconds) + end: number; // unix timestamp (seconds) +}; + +// Secrets +export type GeneratedToken = string; +export type GeneratedCode = string; + +/* -------------------------------------------------------------------------- */ +/* Tables */ +/* -------------------------------------------------------------------------- */ + +export const user = pgTable( + "user", { - createdAt: timestamp("created_at", { mode: "string" }).notNull(), - description: varchar({ length: 512 }).default(sql`NULL`), + createdAt: timestamp("created_at").defaultNow().notNull(), + email: varchar({ length: 255 }).$type().notNull(), id: uuid().primaryKey().notNull(), - isPublic: boolean("is_public").default(false).notNull(), + isConfirmed: boolean("is_confirmed").default(false).notNull(), + isLocked: boolean("is_locked").default(false).notNull(), name: varchar({ length: 255 }).notNull(), - updatedAt: timestamp("updated_at", { mode: "string" }).default(sql`NULL`), - userId: uuid("user_id").notNull(), + password: varchar({ length: 512 }).notNull(), + roles: varchar("roles", { length: 255 }) + .$type() + .array() + .notNull() + .default(["ROLE_USER"]), + updatedAt: timestamp("updated_at"), }, - (table) => [ - index("idx_bookmark_user_created").using( - "btree", - table.userId.asc().nullsLast().op("timestamp_ops"), - table.createdAt.desc().nullsFirst().op("timestamp_ops"), - ), - index("idx_da62921da76ed395").using("btree", table.userId.asc().nullsLast().op("uuid_ops")), - foreignKey({ - columns: [table.userId], - foreignColumns: [user.id], - name: "fk_da62921da76ed395", - }).onDelete("cascade"), + (_table) => [ + uniqueIndex("unq_user_email").using("btree", sql`lower((email)::text)`), + index("idx_user_created_at").using("btree", sql`created_at`), + sql`CONSTRAINT "chk_user_roles_json" CHECK (jsonb_typeof(roles) = 'array')`, ], ); -export const loginAttempt = pgTable( - "login_attempt", +export const source = pgTable( + "source", { - createdAt: timestamp("created_at", { mode: "string" }).notNull(), + credibility: jsonb("credibility").$type(), + description: varchar({ length: 1024 }), + displayName: varchar("display_name", { length: 255 }), id: uuid().primaryKey().notNull(), - userId: uuid("user_id").notNull(), + name: varchar({ length: 255 }).notNull(), + updatedAt: timestamp("updated_at"), + url: varchar({ length: 255 }).notNull(), }, - (table) => [ - index("idx_8c11c1ba76ed395").using("btree", table.userId.asc().nullsLast().op("uuid_ops")), - index("idx_login_attempt_created_at").using( - "btree", - table.createdAt.desc().nullsFirst().op("timestamp_ops"), - ), - foreignKey({ - columns: [table.userId], - foreignColumns: [user.id], - name: "fk_8c11c1ba76ed395", - }).onDelete("cascade"), - ], -); - -export const loginHistory = pgTable( - "login_history", - { - createdAt: timestamp("created_at", { mode: "string" }).notNull(), - deviceClient: varchar("device_client", { length: 255 }).default(sql`NULL`), - deviceDevice: varchar("device_device", { length: 255 }).default(sql`NULL`), - deviceIsBot: boolean("device_is_bot").default(false).notNull(), - deviceOperatingSystem: varchar("device_operating_system", { length: 255 }).default(sql`NULL`), - id: uuid().primaryKey().notNull(), - ipAddress: inet("ip_address"), - locationAccuracyRadius: integer("location_accuracy_radius"), - locationLatitude: doublePrecision("location_latitude"), - locationLongitude: doublePrecision("location_longitude"), - locationTimeZone: varchar("location_time_zone", { length: 255 }).default(sql`NULL`), - userId: uuid("user_id").notNull(), - }, - (table) => [ - index("idx_37976e36a76ed395").using("btree", table.userId.asc().nullsLast().op("uuid_ops")), - index("idx_login_history_created_at").using( - "btree", - table.userId.asc().nullsLast().op("uuid_ops"), - table.createdAt.desc().nullsFirst().op("timestamp_ops"), - ), - index("idx_login_history_ip_address").using( - "btree", - table.ipAddress.asc().nullsLast().op("inet_ops"), - ), - foreignKey({ - columns: [table.userId], - foreignColumns: [user.id], - name: "fk_37976e36a76ed395", - }).onDelete("cascade"), - ], -); - -export const verificationToken = pgTable( - "verification_token", - { - createdAt: timestamp("created_at", { mode: "string" }).notNull(), - id: uuid().primaryKey().notNull(), - purpose: varchar({ length: 255 }).notNull(), - token: varchar({ length: 60 }).default(sql`NULL`), - userId: uuid("user_id").notNull(), - }, - (table) => [ - index("idx_c1cc006ba76ed395").using("btree", table.userId.asc().nullsLast().op("uuid_ops")), - index("idx_verif_token_created_at").using( - "btree", - table.createdAt.desc().nullsFirst().op("timestamp_ops"), - ), - uniqueIndex("unq_verif_user_purpose_token") - .using( - "btree", - table.userId.asc().nullsLast().op("text_ops"), - table.purpose.asc().nullsLast().op("text_ops"), - ) - .where(sql`(token IS NOT NULL)`), - foreignKey({ - columns: [table.userId], - foreignColumns: [user.id], - name: "fk_c1cc006ba76ed395", - }).onDelete("cascade"), - ], -); - -export const followedSource = pgTable( - "followed_source", - { - createdAt: timestamp("created_at", { mode: "string" }).notNull(), - followerId: uuid("follower_id").notNull(), - id: uuid().primaryKey().notNull(), - sourceId: uuid("source_id").notNull(), - }, - (table) => [ - index("idx_7a763a3e953c1c61").using("btree", table.sourceId.asc().nullsLast().op("uuid_ops")), - index("idx_7a763a3eac24f853").using("btree", table.followerId.asc().nullsLast().op("uuid_ops")), - index("idx_followed_source_follower_created").using( - "btree", - table.followerId.asc().nullsLast().op("timestamp_ops"), - table.createdAt.desc().nullsFirst().op("uuid_ops"), - ), - foreignKey({ - columns: [table.followerId], - foreignColumns: [user.id], - name: "fk_7a763a3eac24f853", - }).onDelete("cascade"), - foreignKey({ - columns: [table.sourceId], - foreignColumns: [source.id], - name: "fk_7a763a3e953c1c61", - }).onDelete("cascade"), - ], -); - -export const comment = pgTable( - "comment", - { - articleId: uuid("article_id").notNull(), - content: varchar({ length: 512 }).notNull(), - createdAt: timestamp("created_at", { mode: "string" }).notNull(), - id: uuid().primaryKey().notNull(), - isSpam: boolean("is_spam").default(false).notNull(), - sentiment: varchar({ length: 30 }).default("neutral").notNull(), - userId: uuid("user_id").notNull(), - }, - (table) => [ - index("idx_9474526c7294869c").using("btree", table.articleId.asc().nullsLast().op("uuid_ops")), - index("idx_9474526ca76ed395").using("btree", table.userId.asc().nullsLast().op("uuid_ops")), - index("idx_comment_article_created").using( - "btree", - table.articleId.asc().nullsLast().op("timestamp_ops"), - table.createdAt.desc().nullsFirst().op("uuid_ops"), - ), - foreignKey({ - columns: [table.userId], - foreignColumns: [user.id], - name: "fk_9474526ca76ed395", - }).onDelete("cascade"), - foreignKey({ - columns: [table.articleId], - foreignColumns: [article.id], - name: "fk_9474526c7294869c", - }).onDelete("cascade"), - ], -); - -export const refreshTokens = pgTable( - "refresh_tokens", - { - id: integer().primaryKey().notNull(), - refreshToken: varchar("refresh_token", { length: 128 }).notNull(), - username: varchar({ length: 255 }).notNull(), - valid: timestamp({ mode: "string" }).notNull(), - }, - (table) => [ - uniqueIndex("uniq_9bace7e1c74f2195").using( - "btree", - table.refreshToken.asc().nullsLast().op("text_ops"), - ), + (_table) => [ + uniqueIndex("unq_source_name").using("btree", sql`lower((name)::text)`), + uniqueIndex("unq_source_url").using("btree", sql`lower((url)::text)`), ], ); export const article = pgTable( "article", { - bias: varchar({ length: 30 }).default("neutral").notNull(), body: text().notNull(), categories: text().array(), - crawledAt: timestamp("crawled_at", { mode: "string" }).notNull(), + crawledAt: timestamp("crawled_at").defaultNow().notNull(), + credibility: jsonb("credibility").$type(), excerpt: varchar({ length: 255 }).generatedAlwaysAs(sql`("left"(body, 200) || '...'::text)`), hash: varchar({ length: 32 }).notNull(), id: uuid().primaryKey().notNull(), image: varchar({ length: 1024 }).generatedAlwaysAs(sql`(metadata ->> 'image'::text)`), link: varchar({ length: 1024 }).notNull(), - metadata: jsonb(), - publishedAt: timestamp("published_at", { mode: "string" }).notNull(), + metadata: jsonb("metadata").$type(), + publishedAt: timestamp("published_at").notNull(), readingTime: integer("reading_time").default(1), - reliability: varchar({ length: 30 }).default("reliable").notNull(), - sentiment: varchar({ length: 30 }).default("neutral").notNull(), + sentiment: sentimentEnum("sentiment").notNull(), sourceId: uuid("source_id").notNull(), title: varchar({ length: 1024 }).notNull(), - tokenStatistics: jsonb("token_statistics"), - transparency: varchar({ length: 30 }).default("medium").notNull(), + tokenStatistics: jsonb("token_statistics").$type(), tsv: tsvector("tsv").generatedAlwaysAs( - sql`(setweight(to_tsvector('french'::regconfig, (COALESCE(title, ''::character varying))::text), 'A'::"char") || setweight(to_tsvector('french'::regconfig, COALESCE(body, ''::text)), 'B'::"char"))`, + sql`( + setweight(to_tsvector('french'::regconfig, COALESCE(title, '')::text), 'A'::"char") + || setweight(to_tsvector('french'::regconfig, COALESCE(body, ''::text)), 'B'::"char") + )`, ), - updatedAt: timestamp("updated_at", { mode: "string" }).default(sql`NULL`), + updatedAt: timestamp("updated_at"), }, (table) => [ index("gin_article_categories").using( @@ -264,69 +202,57 @@ export const article = pgTable( index("gin_article_link_trgm").using("gin", table.link.asc().nullsLast().op("gin_trgm_ops")), index("gin_article_title_trgm").using("gin", table.title.asc().nullsLast().op("gin_trgm_ops")), index("gin_article_tsv").using("gin", table.tsv.asc().nullsLast().op("tsvector_ops")), - index("idx_23a0e66953c1c61").using("btree", table.sourceId.asc().nullsLast().op("uuid_ops")), - index("idx_article_published_at").using( + index("idx_article_source_published_id").using( "btree", - table.publishedAt.desc().nullsFirst().op("timestamp_ops"), + table.sourceId.asc().nullsLast(), + table.publishedAt.desc().nullsFirst(), + table.id.desc().nullsFirst(), ), - index("idx_article_published_id").using( - "btree", - table.publishedAt.desc().nullsFirst().op("timestamp_ops"), - table.id.desc().nullsFirst().op("uuid_ops"), - ), - uniqueIndex("unq_article_hash").using("btree", table.hash.asc().nullsLast().op("text_ops")), + uniqueIndex("unq_article_hash").using("btree", table.hash.asc().nullsLast()), foreignKey({ columns: [table.sourceId], foreignColumns: [source.id], - name: "fk_23a0e66953c1c61", + name: "fk_article_source_id", }).onDelete("cascade"), - check("chk_article_reading_time", sql`reading_time >= 0`), + check("chk_article_reading_time", sql`(reading_time >= 0)`), check( "chk_article_sentiment", - sql`(sentiment)::text = ANY ((ARRAY['positive'::character varying, 'neutral'::character varying, 'negative'::character varying])::text[])`, + sql`((sentiment)::text = ANY (ARRAY['positive'::text,'neutral'::text,'negative'::text]))`, ), check( "chk_article_metadata_json", - sql`(metadata IS NULL) OR (jsonb_typeof(metadata) = ANY (ARRAY['object'::text, 'array'::text]))`, + sql`((metadata IS NULL) OR (jsonb_typeof(metadata) IN ('object'::text,'array'::text)))`, ), ], ); -export const user = pgTable( - "user", +export const bookmark = pgTable( + "bookmark", { - createdAt: timestamp("created_at", { mode: "string" }).notNull(), - email: varchar({ length: 255 }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + description: varchar({ length: 512 }), id: uuid().primaryKey().notNull(), - isConfirmed: boolean("is_confirmed").default(false).notNull(), - isLocked: boolean("is_locked").default(false).notNull(), + isPublic: boolean("is_public").default(false).notNull(), name: varchar({ length: 255 }).notNull(), - password: varchar({ length: 512 }).notNull(), - roles: jsonb().notNull(), - updatedAt: timestamp("updated_at", { mode: "string" }).default(sql`NULL`), + updatedAt: timestamp("updated_at"), + userId: uuid("user_id").notNull(), }, - (_table) => [ - uniqueIndex("unq_user_email").using("btree", sql`lower((email)::text)`), - check("chk_user_roles_json", sql`jsonb_typeof(roles) = 'array'::text`), - ], -); - -export const source = pgTable( - "source", - { - bias: varchar({ length: 30 }).default("neutral").notNull(), - description: varchar({ length: 1024 }).default(sql`NULL`), - displayName: varchar("display_name", { length: 255 }).default(sql`NULL`), - id: uuid().primaryKey().notNull(), - name: varchar({ length: 255 }).notNull(), - reliability: varchar({ length: 30 }).default("reliable").notNull(), - transparency: varchar({ length: 30 }).default("medium").notNull(), - updatedAt: timestamp("updated_at", { mode: "string" }).default(sql`NULL`), - url: varchar({ length: 255 }).notNull(), - }, - (_table) => [ - uniqueIndex("unq_source_name").using("btree", sql`lower((name)::text)`), - uniqueIndex("unq_source_url").using("btree", sql`lower((url)::text)`), + (table) => [ + index("idx_bookmark_user_created").using( + "btree", + table.userId.asc().nullsLast(), + table.createdAt.desc().nullsFirst(), + ), + uniqueIndex("unq_bookmark_user_name").using( + "btree", + table.userId.asc().nullsLast(), + sql`lower(${table.name})`, + ), + foreignKey({ + columns: [table.userId], + foreignColumns: [user.id], + name: "fk_bookmark_user_id", + }).onDelete("cascade"), ], ); @@ -337,22 +263,177 @@ export const bookmarkArticle = pgTable( bookmarkId: uuid("bookmark_id").notNull(), }, (table) => [ - index("idx_6fe2655d7294869c").using("btree", table.articleId.asc().nullsLast().op("uuid_ops")), - index("idx_6fe2655d92741d25").using("btree", table.bookmarkId.asc().nullsLast().op("uuid_ops")), + primaryKey({ columns: [table.bookmarkId, table.articleId], name: "bookmark_article_pkey" }), + index("idx_bookmark_article_bookmark_id").using("btree", table.bookmarkId.asc().nullsLast()), foreignKey({ columns: [table.bookmarkId], foreignColumns: [bookmark.id], - name: "fk_6fe2655d92741d25", + name: "fk_bookmark_article_bookmark_id", }).onDelete("cascade"), foreignKey({ columns: [table.articleId], foreignColumns: [article.id], - name: "fk_6fe2655d7294869c", + name: "fk_bookmark_article_article_id", }).onDelete("cascade"), - primaryKey({ columns: [table.bookmarkId, table.articleId], name: "bookmark_article_pkey" }), ], ); +export const loginAttempt = pgTable( + "login_attempt", + { + createdAt: timestamp("created_at").defaultNow().notNull(), + id: uuid().primaryKey().notNull(), + userId: uuid("user_id").notNull(), + }, + (table) => [ + index("idx_login_attempt_user_created").using( + "btree", + table.userId.asc().nullsLast(), + table.createdAt.desc().nullsFirst(), + ), + foreignKey({ + columns: [table.userId], + foreignColumns: [user.id], + name: "fk_login_attempt_user_id", + }).onDelete("cascade"), + ], +); + +export const loginHistory = pgTable( + "login_history", + { + createdAt: timestamp("created_at").defaultNow().notNull(), + device: jsonb("device").$type(), + id: uuid().primaryKey().notNull(), + ipAddress: inet("ip_address"), + location: jsonb("location").$type(), + userId: uuid("user_id").notNull(), + }, + (table) => [ + index("idx_login_history_user_created").using( + "btree", + table.userId.asc().nullsLast(), + table.createdAt.desc().nullsFirst(), + ), + index("idx_login_history_ip_address").using("btree", table.ipAddress.asc().nullsLast()), + foreignKey({ + columns: [table.userId], + foreignColumns: [user.id], + name: "fk_login_history_user_id", + }).onDelete("cascade"), + ], +); + +export const verificationToken = pgTable( + "verification_token", + { + createdAt: timestamp("created_at").defaultNow().notNull(), + id: uuid().primaryKey().notNull(), + purpose: tokenPurposeEnum("purpose").notNull(), + token: varchar({ length: 60 }), // nullable if you support "reservations" before issue + userId: uuid("user_id").notNull(), + }, + (table) => [ + index("idx_verif_token_created_at").using("btree", table.createdAt.desc().nullsFirst()), + uniqueIndex("unq_verif_user_purpose_token") + .using("btree", table.userId, table.purpose, table.token) + .where(sql`${table.token} IS NOT NULL`), + uniqueIndex("unq_verif_token_token") + .using("btree", table.token) + .where(sql`${table.token} IS NOT NULL`), + foreignKey({ + columns: [table.userId], + foreignColumns: [user.id], + name: "fk_verification_token_user_id", + }).onDelete("cascade"), + ], +); + +export const followedSource = pgTable( + "followed_source", + { + createdAt: timestamp("created_at").defaultNow().notNull(), + followerId: uuid("follower_id").notNull(), + id: uuid().primaryKey().notNull(), + sourceId: uuid("source_id").notNull(), + }, + (table) => [ + index("idx_followed_source_source_id").using("btree", table.sourceId.asc().nullsLast()), + index("idx_followed_source_follower_id").using("btree", table.followerId.asc().nullsLast()), + index("idx_followed_source_follower_created").using( + "btree", + table.followerId.asc().nullsLast(), + table.createdAt.desc().nullsFirst(), + ), + uniqueIndex("unq_followed_source_user_source").using( + "btree", + table.followerId.asc().nullsLast(), + table.sourceId.asc().nullsLast(), + ), + foreignKey({ + columns: [table.followerId], + foreignColumns: [user.id], + name: "fk_followed_source_follower_id", + }).onDelete("cascade"), + foreignKey({ + columns: [table.sourceId], + foreignColumns: [source.id], + name: "fk_followed_source_source_id", + }).onDelete("cascade"), + ], +); + +export const comment = pgTable( + "comment", + { + articleId: uuid("article_id").notNull(), + content: varchar({ length: 512 }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + id: uuid().primaryKey().notNull(), + isSpam: boolean("is_spam").default(false).notNull(), + sentiment: sentimentEnum("sentiment").notNull(), + userId: uuid("user_id").notNull(), + }, + (table) => [ + index("idx_comment_article_id").using("btree", table.articleId.asc().nullsLast()), + index("idx_comment_user_id").using("btree", table.userId.asc().nullsLast()), + index("idx_comment_article_created").using( + "btree", + table.articleId.asc().nullsLast(), + table.createdAt.desc().nullsFirst(), + ), + foreignKey({ + columns: [table.userId], + foreignColumns: [user.id], + name: "fk_comment_user_id", + }).onDelete("cascade"), + foreignKey({ + columns: [table.articleId], + foreignColumns: [article.id], + name: "fk_comment_article_id", + }).onDelete("cascade"), + ], +); + +export const refreshToken = pgTable( + "refresh_token", + { + id: uuid().primaryKey().notNull(), + token: varchar("token", { length: 128 }).notNull(), + username: varchar({ length: 255 }).notNull(), + valid: timestamp().notNull(), + }, + (table) => [ + uniqueIndex("uniq_refresh_token_token").using("btree", table.token.asc().nullsLast()), + index("idx_refresh_token_valid").using("btree", table.valid.asc().nullsLast()), + index("idx_refresh_token_username").using("btree", sql`lower(${table.username})`), + ], +); + +/* -------------------------------------------------------------------------- */ +/* Relations */ +/* -------------------------------------------------------------------------- */ + export const bookmarkRelations = relations(bookmark, ({ one, many }) => ({ bookmarkArticles: many(bookmarkArticle), user: one(user, { diff --git a/packages/db/src/utils/computed.ts b/packages/db/src/utils/computed.ts new file mode 100644 index 0000000..fb83d13 --- /dev/null +++ b/packages/db/src/utils/computed.ts @@ -0,0 +1,57 @@ +import { TiktokenEncoding, get_encoding } from "tiktoken"; + +import { TokenStatistics } from "@/schema"; + +/** + * Count the number of tokens in the given text using the specified encoding. + * @param text - The input text + * @param encoding - The token encoding (default: "cl100k_base") + */ +export const computeTokenCount = ( + text: string, + encoding: TiktokenEncoding = "cl100k_base", +): number => { + try { + const encoder = get_encoding(encoding); + const tokens = encoder.encode(text); + encoder.free(); + return tokens.length; + } catch { + return text.length; + } +}; + +/** + * Create token statistics for the given data. + * @param data - The input data containing title, body, and categories + * @returns TokenStatistics object + */ +export const computeTokenStatistics = (data: { + title: string; + body: string; + categories: string[]; +}): TokenStatistics => { + const title = computeTokenCount(data.title); + const body = computeTokenCount(data.body); + const categories = computeTokenCount(data.categories.join(",")); + const excerpt = computeTokenCount(data.body.substring(0, 200)); + + return { + body, + categories, + excerpt, + title, + total: title + body + categories + excerpt, + }; +}; + +/** + * Compute the estimated reading time for the given text. + * @param text - The input text + * @param wordsPerMinute - The reading speed in words per minute (default: 200) + * @returns The estimated reading time in minutes + */ +export const computeReadingTime = (text: string, wordsPerMinute = 200): number => { + const words = text.trim().split(/\s+/).length; + return Math.ceil(words / wordsPerMinute); +}; diff --git a/packages/db/src/utils/pagination.ts b/packages/db/src/utils/pagination.ts index 571bc61..d96cacc 100644 --- a/packages/db/src/utils/pagination.ts +++ b/packages/db/src/utils/pagination.ts @@ -6,8 +6,6 @@ import { PAGINATION_MAX_LIMIT, } from "@/constants"; -export type SortDirection = "asc" | "desc"; - export interface PageRequest { page?: number; limit?: number;