From 4b82a112078cced0c359e1adbfc5a7b49fb10ef7 Mon Sep 17 00:00:00 2001 From: bernard-ng Date: Sun, 9 Nov 2025 16:28:36 +0200 Subject: [PATCH] feat(api): setting up --- apps/api/.env | 4 + apps/api/.gitignore | 4 - apps/api/package.json | 16 +- apps/api/src/config.ts | 40 + apps/api/src/config/config.json | 15 + apps/api/src/config/server.json | 5 + apps/api/src/index.ts | 29 +- apps/crawler/package.json | 6 +- apps/crawler/src/process/persistence.ts | 10 +- apps/crawler/src/schema.ts | 1 + bun.lock | 111 +- docs/architecture.md | 1 + package.json | 5 + packages/db/.env | 1 + packages/db/drizzle.config.ts | 11 +- .../migrations/0000_aromatic_dorian_gray.sql | 172 ++ .../db/migrations/meta/0000_snapshot.json | 1486 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 13 + packages/db/package.json | 6 +- packages/db/src/client.ts | 17 +- packages/db/src/constant.ts | 2 - packages/db/src/constants.ts | 20 + packages/db/src/queries/articles.ts | 547 ------ packages/db/src/queries/bookmarks.ts | 66 - packages/db/src/queries/index.ts | 4 - packages/db/src/queries/sources.ts | 339 ---- packages/db/src/queries/users.ts | 31 - packages/db/src/schema.ts | 683 ++++---- packages/db/src/utils/pagination.ts | 47 +- packages/encryption/package.json | 9 + packages/encryption/src/index.ts | 76 + packages/encryption/tsconfig.json | 5 + packages/logger/package.json | 3 - packages/logger/src/index.ts | 7 +- packages/ui/package.json | 4 +- 35 files changed, 2280 insertions(+), 1516 deletions(-) create mode 100644 apps/api/.env create mode 100644 apps/api/src/config.ts create mode 100644 apps/api/src/config/config.json create mode 100644 apps/api/src/config/server.json create mode 100644 packages/db/.env create mode 100644 packages/db/migrations/0000_aromatic_dorian_gray.sql create mode 100644 packages/db/migrations/meta/0000_snapshot.json create mode 100644 packages/db/migrations/meta/_journal.json delete mode 100644 packages/db/src/constant.ts create mode 100644 packages/db/src/constants.ts delete mode 100644 packages/db/src/queries/bookmarks.ts delete mode 100644 packages/db/src/queries/index.ts delete mode 100644 packages/db/src/queries/users.ts create mode 100644 packages/encryption/package.json create mode 100644 packages/encryption/src/index.ts create mode 100644 packages/encryption/tsconfig.json diff --git a/apps/api/.env b/apps/api/.env new file mode 100644 index 0000000..40bd43e --- /dev/null +++ b/apps/api/.env @@ -0,0 +1,4 @@ +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 diff --git a/apps/api/.gitignore b/apps/api/.gitignore index 36fabb6..053f14c 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -11,10 +11,6 @@ # deps node_modules/ -# env -.env -.env.production - # logs logs/ *.log diff --git a/apps/api/package.json b/apps/api/package.json index fd432a1..8706206 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,7 @@ { "dependencies": { "@basango/db": "workspace:*", + "@basango/encryption": "workspace:*", "@basango/logger": "workspace:*", "@hono/node-server": "^1.19.6", "@hono/zod-openapi": "^1.1.4", @@ -8,24 +9,19 @@ "@trpc/server": "^11.7.1", "ai": "^5.0.89", "camelcase-keys": "^10.0.1", - "date-fns": "^4.1.0", + "date-fns": "catalog:", "hono": "^4.10.4", "hono-rate-limiter": "^0.4.2", "jose": "^6.1.0", - "zod": "^4.1.12", + "zod": "catalog:", "zod-openapi": "^5.4.3" }, - "devDependencies": { - "@types/node": "^20.11.17", - "tsx": "^4.7.1", - "typescript": "^5.8.3" - }, "name": "@basango/api", "private": true, "scripts": { - "build": "tsc", - "dev": "tsx watch src/index.ts", - "start": "node dist/index.js" + "dev": "bun run --hot src/index.ts", + "start": "bun run src/index.ts", + "typecheck": "tsc --noEmit" }, "type": "module" } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..64c50ee --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,40 @@ +import path from "node:path"; + +import { loadConfig as defineConfig } from "@devscast/config"; +import { z } from "zod"; + +export const PROJECT_DIR = path.resolve(__dirname, "../"); + +const ServerConfigurationSchema = z.object({ + cors: z.object({ + allowedHeaders: z.array(z.string()).optional(), + allowMethods: z.array(z.string()).optional(), + exposeHeaders: z.array(z.string()).optional(), + maxAge: z.number().int().min(0).optional(), + origin: z.array(z.string()).default([]), + }), + server: z.object({ + host: z.string().default("localhost"), + port: z.number().int().min(1).max(65535).default(4000), + version: z.string().default("1.0.0"), + }), +}); + +export const { env, config } = defineConfig({ + env: { + knownKeys: [ + "BASANGO_API_HOST", + "BASANGO_API_PORT", + "BASANGO_API_ALLOWED_ORIGINS", + "BASANGO_API_KEY", + ], + path: path.join(PROJECT_DIR, ".env"), + }, + schema: ServerConfigurationSchema, + sources: [ + path.join(PROJECT_DIR, "config", "server.json"), + path.join(PROJECT_DIR, "config", "cors.json"), + ], +}); + +export type ServerConfiguration = z.infer; diff --git a/apps/api/src/config/config.json b/apps/api/src/config/config.json new file mode 100644 index 0000000..ac9ba9c --- /dev/null +++ b/apps/api/src/config/config.json @@ -0,0 +1,15 @@ +{ + "allowedHeaders": [ + "Authorization", + "Content-Type", + "accept-language", + "x-trpc-source", + "x-user-locale", + "x-user-timezone", + "x-user-country" + ], + "allowMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], + "exposeHeaders": ["Content-Length"], + "maxAge": 86400, + "origin": "%env(BASANGO_API_ALLOWED_ORIGINS)%" +} diff --git a/apps/api/src/config/server.json b/apps/api/src/config/server.json new file mode 100644 index 0000000..56c7e52 --- /dev/null +++ b/apps/api/src/config/server.json @@ -0,0 +1,5 @@ +{ + "host": "%env(BASANGO_API_HOST)%", + "port": "%env(number:BASANGO_API_PORT)%", + "version": "1.0.0" +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9066b49..1c84662 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,6 +3,7 @@ import { Scalar } from "@scalar/hono-api-reference"; import { cors } from "hono/cors"; import { secureHeaders } from "hono/secure-headers"; +import { config, env } from "@/config"; import { checkHealth } from "@/utils/health"; const app = new OpenAPIHono(); @@ -12,19 +13,11 @@ app.use(secureHeaders()); app.use( "*", cors({ - allowHeaders: [ - "Authorization", - "Content-Type", - "accept-language", - "x-trpc-source", - "x-user-locale", - "x-user-timezone", - "x-user-country", - ], - allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], - exposeHeaders: ["Content-Length"], - maxAge: 86400, - origin: process.env.BASANGO_API_ALLOWED_ORIGINS?.split(",") ?? [], + allowHeaders: config.cors.allowedHeaders, + allowMethods: config.cors.allowMethods, + exposeHeaders: config.cors.exposeHeaders, + maxAge: config.cors.maxAge, + origin: config.cors.origin, }), ); @@ -54,7 +47,7 @@ app.doc("/openapi", { description: "Basango is a platform that leverages AI to revolutionize news curation.", license: { name: "AGPL-3.0 license", - url: "https://github.com/midday-ai/midday/blob/main/LICENSE", + url: "https://github.com/bernard-ng/basango/blob/main/LICENSE", }, title: "Basango API", version: "0.0.1", @@ -79,7 +72,13 @@ app.openAPIRegistry.registerComponent("securitySchemes", "token", { description: "Default authentication mechanism", scheme: "bearer", type: "http", - "x-speakeasy-example": "BASANGO_API_KEY", + "x-speakeasy-example": env("BASANGO_API_KEY"), }); app.get("/", Scalar({ pageTitle: "Basango API", theme: "saturn", url: "/openapi" })); + +export default { + fetch: app.fetch, + hostname: config.server.host, + port: config.server.port, +}; diff --git a/apps/crawler/package.json b/apps/crawler/package.json index 7d55576..b51da94 100644 --- a/apps/crawler/package.json +++ b/apps/crawler/package.json @@ -1,14 +1,14 @@ { "dependencies": { + "@basango/encryption": "workspace:*", "@basango/logger": "workspace:*", - "@devscast/config": "^1.0.3", "bullmq": "^4.18.3", - "date-fns": "^3.6.0", + "date-fns": "catalog:", "ioredis": "^5.8.2", "node-html-parser": "^7.0.1", "tiktoken": "^1.0.22", "turndown": "^7.2.2", - "zod": "^4.1.12" + "zod": "catalog:" }, "devDependencies": { "@types/turndown": "^5.0.6", diff --git a/apps/crawler/src/process/persistence.ts b/apps/crawler/src/process/persistence.ts index fa51b30..466e48a 100644 --- a/apps/crawler/src/process/persistence.ts +++ b/apps/crawler/src/process/persistence.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; +import { md5 } from "@basango/encryption"; import logger from "@basango/logger"; import { Article } from "@/schema"; @@ -45,11 +46,12 @@ export const persist = async (payload: Article, persistors: Persistor[]): Promis const article = { ...data, + hash: md5(data.link), tokenStatistics: { - body: countTokens(payload.body), - categories: countTokens(payload.categories.join(",")), - excerpt: countTokens(payload.body.substring(0, 200)), - title: countTokens(payload.title), + body: countTokens(data.body), + categories: countTokens(data.categories.join(",")), + excerpt: countTokens(data.body.substring(0, 200)), + title: countTokens(data.title), }, } as Article; diff --git a/apps/crawler/src/schema.ts b/apps/crawler/src/schema.ts index 33d6587..df7274a 100644 --- a/apps/crawler/src/schema.ts +++ b/apps/crawler/src/schema.ts @@ -109,6 +109,7 @@ export const ArticleTokenStatisticsSchema = z.object({ export const ArticleSchema = z.object({ body: z.string(), categories: z.array(z.string()).default([]), + hash: z.string().optional(), link: z.url(), metadata: ArticleMetadataSchema.optional(), source: z.string(), diff --git a/bun.lock b/bun.lock index 19b04b3..08bf13e 100644 --- a/bun.lock +++ b/bun.lock @@ -3,12 +3,17 @@ "workspaces": { "": { "name": "basango", + "dependencies": { + "@devscast/config": "^1.0.3", + }, "devDependencies": { "@basango/tsconfig": "workspace:*", "@biomejs/biome": "^2.3.1", "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@manypkg/cli": "^0.25.1", + "@types/bun": "^1.3.2", + "@types/node": "^24.10.0", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", "husky": "^9.1.7", @@ -20,6 +25,7 @@ "name": "@basango/api", "dependencies": { "@basango/db": "workspace:*", + "@basango/encryption": "workspace:*", "@basango/logger": "workspace:*", "@hono/node-server": "^1.19.6", "@hono/zod-openapi": "^1.1.4", @@ -27,31 +33,26 @@ "@trpc/server": "^11.7.1", "ai": "^5.0.89", "camelcase-keys": "^10.0.1", - "date-fns": "^4.1.0", + "date-fns": "catalog:", "hono": "^4.10.4", "hono-rate-limiter": "^0.4.2", "jose": "^6.1.0", - "zod": "^4.1.12", + "zod": "catalog:", "zod-openapi": "^5.4.3", }, - "devDependencies": { - "@types/node": "^20.11.17", - "tsx": "^4.7.1", - "typescript": "^5.8.3", - }, }, "apps/crawler": { "name": "@basango/crawler", "dependencies": { + "@basango/encryption": "workspace:*", "@basango/logger": "workspace:*", - "@devscast/config": "^1.0.3", "bullmq": "^4.18.3", - "date-fns": "^3.6.0", + "date-fns": "catalog:", "ioredis": "^5.8.2", "node-html-parser": "^7.0.1", "tiktoken": "^1.0.22", "turndown": "^7.2.2", - "zod": "^4.1.12", + "zod": "catalog:", }, "devDependencies": { "@types/turndown": "^5.0.6", @@ -119,21 +120,19 @@ "snakecase-keys": "^9.0.2", }, "devDependencies": { - "@types/bun": "catalog:", "@types/pg": "^8.15.6", "drizzle-kit": "^0.31.6", - "typescript": "catalog:", }, }, + "packages/encryption": { + "name": "@basango/encryption", + }, "packages/logger": { "name": "@basango/logger", "dependencies": { "pino": "^10.1.0", "pino-pretty": "^13.1.2", }, - "devDependencies": { - "typescript": "catalog:", - }, }, "packages/tsconfig": { "name": "@basango/tsconfig", @@ -156,11 +155,9 @@ "@basango/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.1.11", "@turbo/gen": "^2.5.5", - "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "tailwindcss": "^4.1.11", - "typescript": "catalog:", }, }, }, @@ -387,6 +384,8 @@ "@basango/db": ["@basango/db@workspace:packages/db"], + "@basango/encryption": ["@basango/encryption@workspace:packages/encryption"], + "@basango/logger": ["@basango/logger@workspace:packages/logger"], "@basango/mobile": ["@basango/mobile@workspace:apps/mobile"], @@ -909,7 +908,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], + "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -935,7 +934,7 @@ "@types/minimatch": ["@types/minimatch@6.0.0", "", { "dependencies": { "minimatch": "*" } }, "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA=="], - "@types/node": ["@types/node@20.19.24", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA=="], + "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], "@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], @@ -1091,7 +1090,7 @@ "bullmq": ["bullmq@4.18.3", "", { "dependencies": { "cron-parser": "^4.6.0", "glob": "^8.0.3", "ioredis": "^5.3.2", "lodash": "^4.17.21", "msgpackr": "^1.6.2", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", "uuid": "^9.0.0" } }, "sha512-H8t9vhfHEbJDaXp7aalSTe+Do+tR1nvr+lsT+jQxLhy+FFfFj/0p4aYJzADTNLdEqltuxneLVxCGVg92GkQx4w=="], - "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1213,7 +1212,7 @@ "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], - "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], @@ -2311,7 +2310,7 @@ "undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], @@ -2447,7 +2446,7 @@ "@babel/traverse--for-generate-function-map/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - "@basango/crawler/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], + "@basango/dashboard/@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], "@basango/ui/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2531,14 +2530,8 @@ "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], - "@jest/environment/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - - "@jest/fake-timers/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "@jest/transform/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@jest/types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -2585,18 +2578,8 @@ "@turbo/workspaces/semver": ["semver@7.6.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w=="], - "@types/conventional-commits-parser/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - - "@types/glob/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - - "@types/graceful-fs/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "@types/inquirer/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "^1.9.0" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="], - "@types/pg/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - - "@types/through/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], @@ -2611,14 +2594,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=="], - "bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - "chrome-launcher/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - - "chromium-edge-launcher/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "chromium-edge-launcher/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], "commitizen/detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], @@ -2681,18 +2658,10 @@ "istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "jest-environment-node/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - - "jest-haste-map/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "jest-message-util/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "jest-message-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "jest-mock/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - - "jest-util/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -2701,8 +2670,6 @@ "jest-validate/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "jest-worker/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -2825,6 +2792,8 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@basango/dashboard/@types/bun/bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -2943,16 +2912,10 @@ "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "@jest/environment/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@jest/fake-timers/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@jest/transform/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@jest/transform/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@jest/types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -2961,18 +2924,8 @@ "@turbo/workspaces/ora/log-symbols": ["log-symbols@3.0.0", "", { "dependencies": { "chalk": "^2.4.2" } }, "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ=="], - "@types/conventional-commits-parser/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@types/glob/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@types/graceful-fs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/inquirer/rxjs/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "@types/pg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@types/through/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "babel-jest/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -2981,12 +2934,6 @@ "bullmq/glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], - "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "chrome-launcher/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "chromium-edge-launcher/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -3007,18 +2954,10 @@ "inquirer/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-environment-node/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "jest-haste-map/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "jest-message-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-message-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-mock/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "jest-util/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "jest-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -3027,8 +2966,6 @@ "jest-validate/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-worker/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "jest-worker/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], diff --git a/docs/architecture.md b/docs/architecture.md index d103a6a..aef12c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,6 +9,7 @@ Structure - packages/ - logger: `@basango/logger` (pino wrapper) - db: `@basango/db` (Drizzle + Postgres) + - encryption: `@basango/encryption` (encryption utils) - ui: `@basango/ui` (shared UI, package.json in `packages/ui/src`) - tsconfig: `@basango/tsconfig` (shared TS configs) diff --git a/package.json b/package.json index e5168f1..f91e9c9 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,17 @@ "path": "cz-conventional-changelog" } }, + "dependencies": { + "@devscast/config": "^1.0.3" + }, "devDependencies": { "@basango/tsconfig": "workspace:*", "@biomejs/biome": "^2.3.1", "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@manypkg/cli": "^0.25.1", + "@types/bun": "^1.3.2", + "@types/node": "^24.10.0", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", "husky": "^9.1.7", diff --git a/packages/db/.env b/packages/db/.env new file mode 100644 index 0000000..5597a84 --- /dev/null +++ b/packages/db/.env @@ -0,0 +1 @@ +BASANGO_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/app?serverVersion=16&charset=utf8" \ No newline at end of file diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 79c5dc8..b55dfaa 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -1,10 +1,13 @@ -import type { Config } from "drizzle-kit"; +import { createEnvAccessor } from "@devscast/config"; +import { defineConfig } from "drizzle-kit"; -export default { +const env = createEnvAccessor(["BASANGO_DATABASE_URL"] as const); + +export default defineConfig({ dbCredentials: { - url: process.env.DATABASE_URL!, + url: env("BASANGO_DATABASE_URL"), }, dialect: "postgresql", out: "./migrations", schema: "./src/schema.ts", -} satisfies Config; +}); diff --git a/packages/db/migrations/0000_aromatic_dorian_gray.sql b/packages/db/migrations/0000_aromatic_dorian_gray.sql new file mode 100644 index 0000000..b036ff6 --- /dev/null +++ b/packages/db/migrations/0000_aromatic_dorian_gray.sql @@ -0,0 +1,172 @@ +-- 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/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..779f6e0 --- /dev/null +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,1486 @@ +{ + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "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 + } + } + } + } + }, + "policies": {}, + "prevId": "", + "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": {} + } + }, + "version": "7", + "views": {} +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json new file mode 100644 index 0000000..0b385a3 --- /dev/null +++ b/packages/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "dialect": "postgresql", + "entries": [ + { + "breakpoints": true, + "idx": 0, + "tag": "0000_aromatic_dorian_gray", + "version": "7", + "when": 1762691204645 + } + ], + "version": "7" +} diff --git a/packages/db/package.json b/packages/db/package.json index d108747..cde3de5 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -7,16 +7,14 @@ "snakecase-keys": "^9.0.2" }, "devDependencies": { - "@types/bun": "catalog:", "@types/pg": "^8.15.6", - "drizzle-kit": "^0.31.6", - "typescript": "catalog:" + "drizzle-kit": "^0.31.6" }, "exports": { "./client": "./src/client.ts", "./queries": "./src/queries/index.ts", "./schema": "./src/schema.ts", - "./utils": "./src/utils/index.ts" + "./utils": "./src/utils/*" }, "name": "@basango/db", "private": true, diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index b467662..82eb764 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -14,11 +14,20 @@ const connectionConfig = { }; const pool = new Pool({ - connectionString: process.env.DATABASE_URL ?? process.env.DATABASE_PRIMARY_URL!, + connectionString: process.env.BASANGO_DATABASE_URL!, ...connectionConfig, }); -// Lightweight connection pool monitoring (single pool) +/** + * Retrieves runtime statistics for the database connection pool. + * + * This function reads internal pool and connection configuration values and returns + * a snapshot describing pool usage, capacity and utilization. Values that are not + * available on the underlying pool or configuration are normalized to safe defaults + * (zeros or false) so the result is stable. + * + * @returns An object describing the current connection pool statistics and a small summary. + */ export const getConnectionPoolStats = () => { const stats = { active: Math.max(0, (pool.totalCount ?? 0) - (pool.idleCount ?? 0)), @@ -34,9 +43,9 @@ export const getConnectionPoolStats = () => { totalConnections > 0 ? Math.round((stats.active / totalConnections) * 100) : 0; return { - instance: process.env.FLY_ALLOC_ID || "local", + instance: "local", pools: { primary: stats }, - region: process.env.FLY_REGION || "unknown", + region: "unknown", summary: { hasExhaustedPools: stats.active >= totalConnections || (stats.waiting ?? 0) > 0, totalActive: stats.active, diff --git a/packages/db/src/constant.ts b/packages/db/src/constant.ts deleted file mode 100644 index 53ad877..0000000 --- a/packages/db/src/constant.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const SOURCE_IMAGE_BASE = "https://devscast.org/images/sources/"; -export const PUBLICATION_GRAPH_DAYS = 180; diff --git a/packages/db/src/constants.ts b/packages/db/src/constants.ts new file mode 100644 index 0000000..cd91bf8 --- /dev/null +++ b/packages/db/src/constants.ts @@ -0,0 +1,20 @@ +/** + * Base URL for source images. + * This URL is used to construct the full path to source images stored on the server. + */ +export const SOURCE_IMAGE_BASE = "https://devscast.org/images/sources/"; + +/** + * Number of days to include in the publication graph for sources. + * This defines the time range for which publication data is aggregated and displayed. + */ +export const PUBLICATION_GRAPH_DAYS = 180; + +/** + * Default pagination settings. + * These constants define the default page number, default limit per page, + * and maximum limit allowed for paginated requests. + */ +export const PAGINATION_DEFAULT_PAGE = 1; +export const PAGINATION_DEFAULT_LIMIT = 5; +export const PAGINATION_MAX_LIMIT = 100; diff --git a/packages/db/src/queries/articles.ts b/packages/db/src/queries/articles.ts index 616750f..e69de29 100644 --- a/packages/db/src/queries/articles.ts +++ b/packages/db/src/queries/articles.ts @@ -1,547 +0,0 @@ -import type { AnyColumn, SQL } from "drizzle-orm"; -import { and, asc, desc, eq, gt, lt, or, sql } from "drizzle-orm"; - -import type { Database } from "@/client"; -import { articles, bookmarkArticles, bookmarks, comments, sources, users } from "@/schema"; -import { - type PageRequest, - type PageState, - type PaginationMeta, - type SortDirection, - buildPaginationResult, - createPageState, - decodeCursor, -} from "@/utils/pagination"; - -export interface ArticleFilters { - search?: string | null; - category?: string | null; - dateRange?: { start: number; end: number } | null; - sortDirection?: SortDirection; -} - -export interface ArticleOverviewRow { - article_id: string; - articleTitle: string; - articleLink: string; - articleCategories: string | null; - article_excerpt: string | null; - article_published_at: string; - article_image: string | null; - article_reading_time: number | null; - sourceId: string; - source_display_name: string | null; - source_image: string; - sourceUrl: string; - source_name: string; - source_created_at: string; - article_is_bookmarked: boolean; -} - -export interface ArticleOverviewResult { - data: ArticleOverviewRow[]; - pagination: PaginationMeta; -} - -export interface ArticleDetailsRow { - article_id: string; - articleTitle: string; - articleLink: string; - articleCategories: string | null; - articleBody: string; - article_hash: string; - article_published_at: string; - article_crawled_at: string; - article_updated_at: string | null; - article_bias: string; - article_reliability: string; - article_transparency: string; - article_sentiment: string; - article_metadata: unknown; - article_reading_time: number | null; - sourceId: string; - source_name: string; - source_description: string | null; - sourceUrl: string; - source_updated_at: string | null; - source_display_name: string | null; - source_bias: string; - source_reliability: string; - source_transparency: string; - source_image: string; - article_is_bookmarked: boolean; -} - -export interface ArticleCommentRow { - comment_id: string; - comment_content: string; - comment_sentiment: string; - comment_created_at: string; - user_id: string; - user_name: string; -} - -interface NormalizedArticleFilters { - search?: string; - category?: string; - dateRange?: { start: number; end: number } | null; - sortDirection: SortDirection; -} - -export interface ArticleExportRow { - articleId: string; - articleTitle: string; - articleLink: string; - articleCategories: string | null; - articleBody: string; - articleSource: string; - articleHash: string; - articlePublishedAt: string; - articleCrawledAt: string; -} - -export interface ArticleExportParams { - source?: string | null; - dateRange?: { start: number; end: number } | null; - batchSize?: number; -} - -export async function* getArticlesForExport( - db: Database, - params: ArticleExportParams = {}, -): AsyncGenerator { - const batchSize = params.batchSize && params.batchSize > 0 ? params.batchSize : 1000; - - const filters: SQL[] = []; - - if (params.source) { - filters.push(eq(sources.name, params.source)); - } - - if (params.dateRange) { - filters.push( - sql`${articles.publishedAt} BETWEEN to_timestamp( - ${params.dateRange.start} - ) - AND - to_timestamp - ( - ${params.dateRange.end} - )`, - ); - } - - let query = db - .select({ - articleBody: articles.body, - articleCategories: sql`array_to_string - (${articles.categories}, ',')`, - articleCrawledAt: articles.crawledAt, - articleHash: articles.hash, - articleId: articles.id, - articleLink: articles.link, - articlePublishedAt: articles.publishedAt, - articleSource: sources.name, - articleTitle: articles.title, - }) - .from(articles) - .innerJoin(sources, eq(articles.sourceId, sources.id)); - - if (filters.length === 1) { - query = query.where(filters[0]); - } else if (filters.length > 1) { - query = query.where(and(...filters)); - } - - query = query.orderBy(desc(articles.publishedAt), desc(articles.id)); - - let offset = 0; - while (true) { - const rows = await query.limit(batchSize).offset(offset); - if (rows.length === 0) { - break; - } - - for (const row of rows) { - yield { - ...row, - articleCategories: row.articleCategories ?? null, - }; - } - - offset += batchSize; - } -} - -const SOURCE_IMAGE_BASE = "https://devscast.org/images/sources/"; - -function normalizeArticleFilters(filters?: ArticleFilters): NormalizedArticleFilters { - const trimmedSearch = filters?.search?.trim(); - const trimmedCategory = filters?.category?.trim(); - - return { - category: trimmedCategory && trimmedCategory.length > 0 ? trimmedCategory : undefined, - dateRange: filters?.dateRange ?? null, - search: trimmedSearch && trimmedSearch.length > 0 ? trimmedSearch : undefined, - sortDirection: filters?.sortDirection ?? "desc", - }; -} - -function buildArticleFilterConditions(filters: NormalizedArticleFilters): { - conditions: SQL[]; - searchQuery?: string; -} { - const conditions: SQL[] = []; - let searchQuery: string | undefined; - - if (filters.category) { - conditions.push(sql`${filters.category} = ANY( - ${articles.categories} - )`); - } - - if (filters.search) { - const sanitized = filters.search.replace(/\s+/g, " & "); - if (sanitized.length > 0) { - searchQuery = sanitized; - conditions.push( - sql`${articles.tsv} @@ to_tsquery('french', - ${sanitized} - )`, - ); - } - } - - if (filters.dateRange) { - conditions.push( - sql`${articles.publishedAt} BETWEEN to_timestamp( - ${filters.dateRange.start} - ) - AND - to_timestamp - ( - ${filters.dateRange.end} - )`, - ); - } - - return { conditions, searchQuery }; -} - -function buildBookmarkExistsExpression(userId: string): SQL { - return sql`EXISTS - (SELECT 1 - FROM ${bookmarkArticles} ba - INNER JOIN ${bookmarks} b ON ba.bookmark_id = b.id - WHERE ba.article_id = ${articles.id} - AND b.user_id = ${userId})`; -} - -async function fetchArticleOverview( - db: Database, - options: { - userId: string; - page: PageState; - filters: NormalizedArticleFilters; - baseConditions?: SQL[]; - }, -): Promise { - const baseConditions = options.baseConditions ?? []; - const { conditions: filterConditions, searchQuery } = buildArticleFilterConditions( - options.filters, - ); - const whereConditions = [...baseConditions, ...filterConditions]; - - const bookmarkExpression = buildBookmarkExistsExpression(options.userId); - - const selectFields = { - article_excerpt: articles.excerpt, - article_id: articles.id, - article_image: articles.image, - article_is_bookmarked: bookmarkExpression, - article_published_at: articles.publishedAt, - article_reading_time: articles.readingTime, - articleCategories: sql`array_to_string - (${articles.categories}, ',')`, - articleLink: articles.link, - articleTitle: articles.title, - source_created_at: sources.createdAt, - source_display_name: sources.displayName, - source_image: sql`('${SOURCE_IMAGE_BASE}' || ${sources.name} || '.png')`, - source_name: sources.name, - sourceId: sources.id, - sourceUrl: sources.url, - } satisfies Record; - - let query = db - .select(selectFields) - .from(articles) - .innerJoin(sources, eq(articles.sourceId, sources.id)); - - const cursor = decodeCursor(options.page.cursor); - if (cursor?.date) { - const comparison = - options.filters.sortDirection === "asc" - ? or( - gt(articles.publishedAt, cursor.date), - and(eq(articles.publishedAt, cursor.date), gt(articles.id, cursor.id)), - ) - : or( - lt(articles.publishedAt, cursor.date), - and(eq(articles.publishedAt, cursor.date), lt(articles.id, cursor.id)), - ); - whereConditions.push(comparison); - } - - if (whereConditions.length === 1) { - query = query.where(whereConditions[0]); - } else if (whereConditions.length > 1) { - query = query.where(and(...whereConditions)); - } - - const orderings: (SQL | AnyColumn)[] = []; - if (searchQuery) { - orderings.push( - options.filters.sortDirection === "asc" - ? sql`ts_rank - (${articles.tsv}, to_tsquery('french', ${searchQuery})) - ASC` - : sql`ts_rank - (${articles.tsv}, to_tsquery('french', ${searchQuery})) - DESC`, - ); - } - - if (options.filters.sortDirection === "asc") { - orderings.push(asc(articles.publishedAt), asc(articles.id)); - } else { - orderings.push(desc(articles.publishedAt), desc(articles.id)); - } - - const rows = await query.orderBy(...orderings).limit(options.page.limit + 1); - - return buildPaginationResult(rows, options.page, { - date: "article_published_at", - id: "article_id", - }); -} - -export async function getArticleOverviewList( - db: Database, - params: { - userId: string; - page?: PageRequest; - filters?: ArticleFilters; - }, -): Promise { - const page = createPageState(params.page); - const filters = normalizeArticleFilters(params.filters); - - return fetchArticleOverview(db, { - filters, - page, - userId: params.userId, - }); -} - -export async function getSourceArticleOverviewList( - db: Database, - params: { - sourceId: string; - userId: string; - page?: PageRequest; - filters?: ArticleFilters; - }, -): Promise { - const page = createPageState(params.page); - const filters = normalizeArticleFilters(params.filters); - - return fetchArticleOverview(db, { - baseConditions: [eq(sources.id, params.sourceId)], - filters, - page, - userId: params.userId, - }); -} - -export async function getBookmarkedArticleList( - db: Database, - params: { - userId: string; - bookmarkId: string; - page?: PageRequest; - filters?: ArticleFilters; - }, -): Promise { - const page = createPageState(params.page); - const filters = normalizeArticleFilters(params.filters); - const { conditions: filterConditions, searchQuery } = buildArticleFilterConditions(filters); - - const whereConditions: SQL[] = [ - eq(bookmarks.id, params.bookmarkId), - eq(bookmarks.userId, params.userId), - ...filterConditions, - ]; - - const selectFields = { - article_excerpt: articles.excerpt, - article_id: articles.id, - article_image: articles.image, - article_is_bookmarked: sql`true`, - article_published_at: articles.publishedAt, - article_reading_time: articles.readingTime, - articleCategories: sql`array_to_string - (${articles.categories}, ',')`, - articleLink: articles.link, - articleTitle: articles.title, - source_created_at: sources.createdAt, - source_display_name: sources.displayName, - source_image: sql`('${SOURCE_IMAGE_BASE}' || ${sources.name} || '.png')`, - source_name: sources.name, - sourceId: sources.id, - sourceUrl: sources.url, - } satisfies Record; - - let query = db - .select(selectFields) - .from(bookmarkArticles) - .innerJoin(articles, eq(bookmarkArticles.articleId, articles.id)) - .innerJoin(bookmarks, eq(bookmarkArticles.bookmarkId, bookmarks.id)) - .innerJoin(sources, eq(articles.sourceId, sources.id)); - - const cursor = decodeCursor(page.cursor); - if (cursor?.date) { - const comparison = - filters.sortDirection === "asc" - ? or( - gt(articles.publishedAt, cursor.date), - and(eq(articles.publishedAt, cursor.date), gt(articles.id, cursor.id)), - ) - : or( - lt(articles.publishedAt, cursor.date), - and(eq(articles.publishedAt, cursor.date), lt(articles.id, cursor.id)), - ); - whereConditions.push(comparison); - } - - if (whereConditions.length === 1) { - query = query.where(whereConditions[0]); - } else if (whereConditions.length > 1) { - query = query.where(and(...whereConditions)); - } - - const orderings: (SQL | AnyColumn)[] = []; - if (searchQuery) { - orderings.push( - filters.sortDirection === "asc" - ? sql`ts_rank - (${articles.tsv}, to_tsquery('french', ${searchQuery})) - ASC` - : sql`ts_rank - (${articles.tsv}, to_tsquery('french', ${searchQuery})) - DESC`, - ); - } - - if (filters.sortDirection === "asc") { - orderings.push(asc(articles.publishedAt), asc(articles.id)); - } else { - orderings.push(desc(articles.publishedAt), desc(articles.id)); - } - - const rows = await query.orderBy(...orderings).limit(page.limit + 1); - - return buildPaginationResult(rows, page, { - date: "article_published_at", - id: "article_id", - }); -} - -export async function getArticleDetails( - db: Database, - params: { id: string; userId: string }, -): Promise { - const bookmarkExpression = buildBookmarkExistsExpression(params.userId); - - const [row] = await db - .select({ - article_bias: articles.bias, - article_crawled_at: articles.crawledAt, - article_hash: articles.hash, - article_id: articles.id, - article_is_bookmarked: bookmarkExpression, - article_metadata: articles.metadata, - article_published_at: articles.publishedAt, - article_reading_time: articles.readingTime, - article_reliability: articles.reliability, - article_sentiment: articles.sentiment, - article_transparency: articles.transparency, - article_updated_at: articles.updatedAt, - articleBody: articles.body, - articleCategories: sql`array_to_string - (${articles.categories}, ',')`, - articleLink: articles.link, - articleTitle: articles.title, - source_bias: sources.bias, - source_description: sources.description, - source_display_name: sources.displayName, - source_image: sql`('${SOURCE_IMAGE_BASE}' || ${sources.name} || '.png')`, - source_name: sources.name, - source_reliability: sources.reliability, - source_transparency: sources.transparency, - source_updated_at: sources.updatedAt, - sourceId: sources.id, - sourceUrl: sources.url, - }) - .from(articles) - .innerJoin(sources, eq(articles.sourceId, sources.id)) - .where(eq(articles.id, params.id)) - .limit(1); - - return row ?? null; -} - -export async function getArticleCommentList( - db: Database, - params: { articleId: string; page?: PageRequest }, -): Promise<{ data: ArticleCommentRow[]; pagination: PaginationMeta }> { - const page = createPageState(params.page); - const whereConditions: SQL[] = [eq(comments.articleId, params.articleId)]; - - const cursor = decodeCursor(page.cursor); - if (cursor?.date) { - whereConditions.push( - or( - lt(comments.createdAt, cursor.date), - and(eq(comments.createdAt, cursor.date), lt(comments.id, cursor.id)), - ), - ); - } - - let query = db - .select({ - comment_content: comments.content, - comment_created_at: comments.createdAt, - comment_id: comments.id, - comment_sentiment: comments.sentiment, - user_id: users.id, - user_name: users.name, - }) - .from(comments) - .innerJoin(users, eq(comments.userId, users.id)); - - if (whereConditions.length === 1) { - query = query.where(whereConditions[0]); - } else if (whereConditions.length > 1) { - query = query.where(and(...whereConditions)); - } - - const rows = await query - .orderBy(desc(comments.createdAt), desc(comments.id)) - .limit(page.limit + 1); - - return buildPaginationResult(rows, page, { - date: "comment_created_at", - id: "comment_id", - }); -} diff --git a/packages/db/src/queries/bookmarks.ts b/packages/db/src/queries/bookmarks.ts deleted file mode 100644 index 10504af..0000000 --- a/packages/db/src/queries/bookmarks.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { SQL } from "drizzle-orm"; -import { and, desc, eq, lt, sql } from "drizzle-orm"; - -import type { Database } from "@/client"; -import { bookmarkArticles, bookmarks } from "@/schema"; -import { - type PageRequest, - type PaginationMeta, - buildPaginationResult, - createPageState, - decodeCursor, -} from "@/utils/pagination"; - -export interface BookmarkRow { - bookmark_id: string; - bookmark_name: string; - bookmark_description: string | null; - bookmark_created_at: string; - bookmark_updated_at: string | null; - bookmark_articles_count: number; - bookmark_is_public: boolean; -} - -export interface BookmarkListResult { - data: BookmarkRow[]; - pagination: PaginationMeta; -} - -export async function getBookmarkList( - db: Database, - params: { userId: string; page?: PageRequest }, -): Promise { - const page = createPageState(params.page); - const whereConditions: SQL[] = [eq(bookmarks.userId, params.userId)]; - - const cursor = decodeCursor(page.cursor); - if (cursor?.id) { - whereConditions.push(lt(bookmarks.id, cursor.id)); - } - - let query = db - .select({ - bookmark_articles_count: sql`count(${bookmarkArticles.articleId})`, - bookmark_created_at: bookmarks.createdAt, - bookmark_description: bookmarks.description, - bookmark_id: bookmarks.id, - bookmark_is_public: bookmarks.isPublic, - bookmark_name: bookmarks.name, - bookmark_updated_at: bookmarks.updatedAt, - }) - .from(bookmarks) - .leftJoin(bookmarkArticles, eq(bookmarkArticles.bookmarkId, bookmarks.id)) - .groupBy(bookmarks.id); - - if (whereConditions.length === 1) { - query = query.where(whereConditions[0]); - } else if (whereConditions.length > 1) { - query = query.where(and(...whereConditions)); - } - - const rows = await query - .orderBy(desc(bookmarks.createdAt), desc(bookmarks.id)) - .limit(page.limit + 1); - - return buildPaginationResult(rows, page, { id: "bookmark_id" }); -} diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts deleted file mode 100644 index 3b24f06..0000000 --- a/packages/db/src/queries/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./articles"; -export * from "./bookmarks"; -export * from "./sources"; -export * from "./users"; diff --git a/packages/db/src/queries/sources.ts b/packages/db/src/queries/sources.ts index de7325a..e69de29 100644 --- a/packages/db/src/queries/sources.ts +++ b/packages/db/src/queries/sources.ts @@ -1,339 +0,0 @@ -import type { SQL } from "drizzle-orm"; -import { and, desc, eq, lt, or, sql } from "drizzle-orm"; - -import type { Database } from "@/client"; -import { PUBLICATION_GRAPH_DAYS, SOURCE_IMAGE_BASE } from "@/constant"; -import { articles, followedSources, sources } from "@/schema"; -import { - type PageRequest, - type PaginationMeta, - buildPaginationResult, - createPageState, - decodeCursor, -} from "@/utils/pagination"; - -export interface SourceOverviewRow { - sourceId: string; - source_display_name: string | null; - source_image: string; - sourceUrl: string; - source_name: string; - source_created_at: string; - source_is_followed: boolean; -} - -export interface SourceOverviewResult { - data: SourceOverviewRow[]; - pagination: PaginationMeta; -} - -export interface PublicationEntry { - day: string; - count: number; -} - -export interface CategoryShare { - category: string; - count: number; - percentage: number; -} - -export interface SourceDetailsResult { - source: { - sourceId: string; - source_name: string; - source_description: string | null; - sourceUrl: string; - source_updated_at: string | null; - source_display_name: string | null; - source_bias: string; - source_reliability: string; - source_transparency: string; - source_image: string; - articles_count: number; - source_crawled_at: string | null; - articles_metadata_available: number; - source_is_followed: boolean; - }; - publicationGraph: PublicationEntry[]; - categoryShares: CategoryShare[]; -} - -export interface SourceStatisticsRow { - sourceId: string; - sourceName: string; - sourceCrawledAt: string | null; - articlesCount: number; - articleMetadataAvailable: number; -} - -export async function getSourceStatisticsList(db: Database): Promise { - const rows = await db - .select({ - articleMetadataAvailable: sql`sum - (CASE WHEN ${articles.metadata} IS NOT NULL THEN 1 ELSE 0 END)`, - articlesCount: sql`count - (${articles.id})`, - sourceCrawledAt: sql`max - (${articles.crawledAt})`, - sourceId: sources.id, - sourceName: sources.name, - }) - .from(sources) - .leftJoin(articles, eq(articles.sourceId, sources.id)) - .groupBy(sources.id, sources.name) - .orderBy(sources.name.asc()); - - return rows.map((row) => ({ - articleMetadataAvailable: Number(row.articleMetadataAvailable ?? 0), - articlesCount: Number(row.articlesCount ?? 0), - sourceCrawledAt: row.sourceCrawledAt, - sourceId: row.sourceId, - sourceName: row.sourceName, - })); -} - -export interface PublicationDateParams { - source: string; - category?: string | null; -} - -async function selectPublicationBoundary( - db: Database, - fn: "min" | "max", - params: PublicationDateParams, -): Promise { - const conditions: SQL[] = [eq(sources.name, params.source)]; - - if (params.category) { - conditions.push(sql`${params.category} = ANY(${articles.categories})`); - } - - const whereClause = conditions.length > 1 ? and(...conditions) : conditions[0]; - - const [result] = await db - .select({ - boundary: - fn === "min" - ? sql`min - (${articles.publishedAt})` - : sql`max - (${articles.publishedAt})`, - }) - .from(articles) - .innerJoin(sources, eq(articles.sourceId, sources.id)) - .where(whereClause); - - return result?.boundary ?? new Date().toISOString(); -} - -export async function getEarliestPublicationDate( - db: Database, - params: PublicationDateParams, -): Promise { - return selectPublicationBoundary(db, "min", params); -} - -export async function getLatestPublicationDate( - db: Database, - params: PublicationDateParams, -): Promise { - return selectPublicationBoundary(db, "max", params); -} - -function buildFollowExistsExpression(userId: string): SQL { - return sql`EXISTS - (SELECT 1 - FROM ${followedSources} f - WHERE f.sourceId = ${sources.id} - AND f.follower_id = ${userId})`; -} - -export async function getSourceOverviewList( - db: Database, - params: { userId: string; page?: PageRequest }, -): Promise { - const page = createPageState(params.page); - const followExpression = buildFollowExistsExpression(params.userId); - - let query = db - .select({ - source_created_at: sources.createdAt, - source_display_name: sources.displayName, - source_image: sql`('${SOURCE_IMAGE_BASE}' || ${sources.name} || '.png')`, - source_is_followed: followExpression, - source_name: sources.name, - sourceId: sources.id, - sourceUrl: sources.url, - }) - .from(sources); - - const cursor = decodeCursor(page.cursor); - if (cursor?.date) { - query = query.where( - or( - lt(sources.createdAt, cursor.date), - and(eq(sources.createdAt, cursor.date), lt(sources.id, cursor.id)), - ), - ); - } - - const rows = await query.orderBy(desc(sources.createdAt), desc(sources.id)).limit(page.limit + 1); - - return buildPaginationResult(rows, page, { - date: "source_created_at", - id: "sourceId", - }); -} - -function createBackwardDateRange(days: number): { start: number; end: number } { - const now = new Date(); - const end = Math.floor((now.getTime() + 86_400_000) / 1000); - const startDate = new Date(now.getTime() - days * 86_400_000); - const start = Math.floor(startDate.getTime() / 1000); - - return { end, start }; -} - -async function fetchPublicationGraph(db: Database, sourceId: string): Promise { - const range = createBackwardDateRange(PUBLICATION_GRAPH_DAYS); - - const rows = await db - .select({ - count: sql`count - (${articles.id})`, - day: sql`date - (${articles.publishedAt})`, - }) - .from(articles) - .where(eq(articles.sourceId, sourceId)) - .where( - sql`${articles.publishedAt} BETWEEN to_timestamp( - ${range.start} - ) - AND - to_timestamp - ( - ${range.end} - )`, - ) - .groupBy(sql`date - (${articles.publishedAt})`) - .orderBy(sql`date - (${articles.publishedAt})`); - - const counts = new Map(); - for (const row of rows) { - counts.set(row.day, Number(row.count ?? 0)); - } - - const entries: PublicationEntry[] = []; - const start = new Date(range.start * 1000); - const end = new Date(range.end * 1000); - - for (let date = new Date(start.getTime()); date < end; date.setUTCDate(date.getUTCDate() + 1)) { - const day = date.toISOString().slice(0, 10); - entries.push({ count: counts.get(day) ?? 0, day }); - } - - return entries; -} - -async function fetchCategoryShares(db: Database, sourceId: string): Promise { - const rows = await db - .select({ - categories: sql`array_to_string - (${articles.categories}, ',')`, - }) - .from(articles) - .where(eq(articles.sourceId, sourceId)); - - const counts = new Map(); - for (const row of rows) { - if (!row.categories) continue; - for (const category of row.categories.split(",")) { - const normalized = category.trim(); - if (normalized.length === 0) continue; - counts.set(normalized, (counts.get(normalized) ?? 0) + 1); - } - } - - const total = Array.from(counts.values()).reduce((acc, value) => acc + value, 0); - - const shares: CategoryShare[] = Array.from(counts.entries()).map(([category, count]) => ({ - category, - count, - percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0, - })); - - shares.sort((a, b) => b.count - a.count); - return shares; -} - -export async function getSourceDetails( - db: Database, - params: { sourceId: string; userId: string }, -): Promise { - const followExpression = buildFollowExistsExpression(params.userId); - - const [row] = await db - .select({ - articles_count: sql`count - (${articles.id})`, - articles_metadata_available: sql`count - (*) - FILTER (WHERE - ${articles.metadata} - IS - NOT - NULL - )`, - source_bias: sources.bias, - source_crawled_at: sql`max - (${articles.crawledAt})`, - source_description: sources.description, - source_display_name: sources.displayName, - source_image: sql`('${SOURCE_IMAGE_BASE}' || ${sources.name} || '.png')`, - source_is_followed: followExpression, - source_name: sources.name, - source_reliability: sources.reliability, - source_transparency: sources.transparency, - source_updated_at: sources.updatedAt, - sourceId: sources.id, - sourceUrl: sources.url, - }) - .from(sources) - .leftJoin(articles, eq(articles.sourceId, sources.id)) - .where(eq(sources.id, params.sourceId)) - .groupBy( - sources.id, - sources.name, - sources.description, - sources.url, - sources.updatedAt, - sources.displayName, - sources.bias, - sources.reliability, - sources.transparency, - ) - .limit(1); - - if (!row) { - return null; - } - - const [publicationGraph, categoryShares] = await Promise.all([ - fetchPublicationGraph(db, params.sourceId), - fetchCategoryShares(db, params.sourceId), - ]); - - return { - categoryShares, - publicationGraph, - source: { - ...row, - articles_count: Number(row.articles_count ?? 0), - articles_metadata_available: Number(row.articles_metadata_available ?? 0), - }, - }; -} diff --git a/packages/db/src/queries/users.ts b/packages/db/src/queries/users.ts deleted file mode 100644 index f67f09b..0000000 --- a/packages/db/src/queries/users.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { eq } from "drizzle-orm"; - -import type { Database } from "@/client"; -import { users } from "@/schema"; - -export interface UserProfileRow { - user_id: string; - user_name: string; - user_email: string; - user_created_at: string; - user_updated_at: string | null; -} - -export async function getUserProfile( - db: Database, - params: { userId: string }, -): Promise { - const [row] = await db - .select({ - user_created_at: users.createdAt, - user_email: users.email, - user_id: users.id, - user_name: users.name, - user_updated_at: users.updatedAt, - }) - .from(users) - .where(eq(users.id, params.userId)) - .limit(1); - - return row ?? null; -} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 8e46470..c2b23df 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,6 +1,7 @@ import { relations, sql } from "drizzle-orm"; import { boolean, + check, customType, doublePrecision, foreignKey, @@ -8,326 +9,207 @@ import { inet, integer, jsonb, - pgEnum, + pgSequence, pgTable, primaryKey, text, timestamp, - unique, uniqueIndex, uuid, varchar, } from "drizzle-orm/pg-core"; -export const tsvector = customType<{ - data: string; -}>({ +const tsvector = customType<{ data: string; driverData: string }>({ dataType() { return "tsvector"; }, }); -type NumericConfig = { - precision?: number; - scale?: number; -}; - -export const numericCasted = customType<{ - data: number; - driverData: string; - config: NumericConfig; -}>({ - dataType: (config) => { - if (config?.precision && config?.scale) { - return `numeric(${config.precision}, ${config.scale})`; - } - return "numeric"; - }, - fromDriver: (value: string) => Number.parseFloat(value), - toDriver: (value: number) => value.toString(), +export const refreshTokensIdSeq = pgSequence("refresh_tokens_id_seq", { + cache: "1", + cycle: false, + increment: "1", + maxValue: "9223372036854775807", + minValue: "1", + startWith: "1", }); -export const articleSentimentEnum = pgEnum("article_sentiment", [ - "positive", - "neutral", - "negative", -]); +// 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 transparencyEnum = pgEnum("transparency", ["high", "medium", "low"]); - -export const verificationTokenPurposeEnum = pgEnum("verification_token_purpose", [ - "confirm_account", - "password_reset", - "unlock_account", - "delete_account", -]); - -export const sources = pgTable( - "source", - { - bias: biasEnum("bias").notNull().default("neutral"), - createdAt: timestamp("created_at", { mode: "string" }).defaultNow().notNull(), - description: varchar("description", { length: 1024 }), - displayName: varchar("display_name", { length: 255 }), - id: uuid("id").notNull().defaultRandom().primaryKey(), - name: varchar("name", { length: 255 }).notNull(), - reliability: reliabilityEnum("reliability").notNull().default("reliable"), - transparency: transparencyEnum("transparency").notNull().default("medium"), - updatedAt: timestamp("updated_at", { mode: "string" }), - url: varchar("url", { length: 255 }).notNull(), - }, - (table) => [ - uniqueIndex("unq_source_name").using( - "btree", - sql`lower - (${table.name})`, - ), - uniqueIndex("unq_sourceUrl").using( - "btree", - sql`lower - (${table.url})`, - ), - ], -); - -export const articles = pgTable( - "article", - { - bias: biasEnum("bias").notNull().default("neutral"), - body: text("body").notNull(), - categories: text("categories").array(), - crawledAt: timestamp("crawled_at", { mode: "string" }).notNull(), - excerpt: varchar("excerpt", { length: 255 }).generatedAlwaysAs( - () => sql`((left(body, 200) || '...'))`, - ), - hash: varchar("hash", { length: 32 }).notNull(), - id: uuid("id").notNull().defaultRandom().primaryKey(), - image: varchar("image", { length: 1024 }).generatedAlwaysAs(() => sql`(metadata->>'image')`), - link: varchar("link", { length: 1024 }).notNull(), - metadata: jsonb("metadata"), - publishedAt: timestamp("published_at", { mode: "string" }).notNull(), - readingTime: integer("reading_time").default(1), - reliability: reliabilityEnum("reliability").notNull().default("reliable"), - sentiment: articleSentimentEnum("sentiment").notNull().default("neutral"), - sourceId: uuid("sourceId").notNull(), - title: varchar("title", { length: 1024 }).notNull(), - tokenStatistics: jsonb("token_statistics"), - transparency: transparencyEnum("transparency").notNull().default("medium"), - tsv: tsvector("tsv").generatedAlwaysAs( - () => sql`( - setweight(to_tsvector('french', coalesce(title, '')), 'A') - || setweight(to_tsvector('french', coalesce(body, '')), 'B') - )`, - ), - updatedAt: timestamp("updated_at", { mode: "string" }), - }, - (table) => [ - index("article_sourceId_idx").on(table.sourceId), - index("idx_article_published_at").using("btree", table.publishedAt.desc()), - index("idx_article_published_id").using("btree", table.publishedAt.desc(), table.id.desc()), - unique("unq_article_hash").on(table.hash), - index("gin_article_tsv").using("gin", table.tsv), - index("gin_articleLink_trgm").using("gin", table.link.op("gin_trgm_ops")), - index("gin_articleTitle_trgm").using("gin", table.title.op("gin_trgm_ops")), - index("gin_articleCategories").using("gin", table.categories), - foreignKey({ - columns: [table.sourceId], - foreignColumns: [sources.id], - name: "article_sourceId_fkey", - }).onDelete("cascade"), - { - expression: sql`reading_time >= 0`, - kind: "check", - name: "chk_article_reading_time", - }, - { - expression: sql`(metadata IS NULL OR jsonb_typeof(metadata) IN ('object','array'))`, - kind: "check", - name: "chk_article_metadata_json", - }, - ], -); - -export const users = pgTable( - "user", - { - createdAt: timestamp("created_at", { mode: "string" }).notNull(), - email: varchar("email", { length: 255 }).notNull(), - id: uuid("id").notNull().defaultRandom().primaryKey(), - isConfirmed: boolean("is_confirmed").notNull().default(false), - isLocked: boolean("is_locked").notNull().default(false), - name: varchar("name", { length: 255 }).notNull(), - password: varchar("password", { length: 512 }).notNull(), - roles: jsonb("roles").notNull(), - updatedAt: timestamp("updated_at", { mode: "string" }), - }, - (table) => [ - uniqueIndex("unq_user_email").using("btree", sql`lower (${table.email})`), - { - expression: sql`jsonb_typeof(roles) = 'array'`, - kind: "check", - name: "chk_user_roles_array", - }, - ], -); - -export const bookmarks = pgTable( +export const bookmark = pgTable( "bookmark", { createdAt: timestamp("created_at", { mode: "string" }).notNull(), - description: varchar("description", { length: 512 }), - id: uuid("id").notNull().defaultRandom().primaryKey(), - isPublic: boolean("is_public").notNull().default(false), - name: varchar("name", { length: 255 }).notNull(), - updatedAt: timestamp("updated_at", { mode: "string" }), + description: varchar({ length: 512 }).default(sql`NULL`), + id: uuid().primaryKey().notNull(), + isPublic: boolean("is_public").default(false).notNull(), + name: varchar({ length: 255 }).notNull(), + updatedAt: timestamp("updated_at", { mode: "string" }).default(sql`NULL`), userId: uuid("user_id").notNull(), }, (table) => [ - index("bookmark_user_id_idx").on(table.userId), - index("idx_bookmark_user_created").using("btree", table.userId, table.createdAt.desc()), - foreignKey({ - columns: [table.userId], - foreignColumns: [users.id], - name: "bookmark_user_id_fkey", - }).onDelete("cascade"), - ], -); - -export const bookmarkArticles = pgTable( - "bookmark_article", - { - articleId: uuid("article_id").notNull(), - bookmarkId: uuid("bookmark_id").notNull(), - }, - (table) => [ - primaryKey({ - columns: [table.bookmarkId, table.articleId], - name: "bookmark_article_pkey", - }), - index("bookmark_article_bookmark_idx").on(table.bookmarkId), - index("bookmark_article_article_idx").on(table.articleId), - foreignKey({ - columns: [table.bookmarkId], - foreignColumns: [bookmarks.id], - name: "bookmark_article_bookmark_id_fkey", - }).onDelete("cascade"), - foreignKey({ - columns: [table.articleId], - foreignColumns: [articles.id], - name: "bookmark_article_article_id_fkey", - }).onDelete("cascade"), - ], -); - -export const comments = pgTable( - "comment", - { - articleId: uuid("article_id").notNull(), - content: varchar("content", { length: 512 }).notNull(), - createdAt: timestamp("created_at", { mode: "string" }).notNull(), - id: uuid("id").notNull().defaultRandom().primaryKey(), - isSpam: boolean("is_spam").notNull().default(false), - sentiment: articleSentimentEnum("sentiment").notNull().default("neutral"), - userId: uuid("user_id").notNull(), - }, - (table) => [ - index("comment_user_id_idx").on(table.userId), - index("comment_article_id_idx").on(table.articleId), - index("idx_comment_article_created").using("btree", table.articleId, table.createdAt.desc()), - foreignKey({ - columns: [table.userId], - foreignColumns: [users.id], - name: "comment_user_id_fkey", - }).onDelete("cascade"), - foreignKey({ - columns: [table.articleId], - foreignColumns: [articles.id], - name: "comment_article_id_fkey", - }).onDelete("cascade"), - ], -); - -export const followedSources = pgTable( - "followed_source", - { - createdAt: timestamp("created_at", { mode: "string" }).notNull(), - followerId: uuid("follower_id").notNull(), - id: uuid("id").notNull().defaultRandom().primaryKey(), - sourceId: uuid("sourceId").notNull(), - }, - (table) => [ - index("followed_source_follower_idx").on(table.followerId), - index("followed_source_sourceIdx").on(table.sourceId), - index("idx_followed_source_follower_created").using( + index("idx_bookmark_user_created").using( "btree", - table.followerId, - table.createdAt.desc(), + 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.followerId], - foreignColumns: [users.id], - name: "followed_source_follower_id_fkey", - }).onDelete("cascade"), - foreignKey({ - columns: [table.sourceId], - foreignColumns: [sources.id], - name: "followed_source_sourceId_fkey", + columns: [table.userId], + foreignColumns: [user.id], + name: "fk_da62921da76ed395", }).onDelete("cascade"), ], ); -export const loginAttempts = pgTable( +export const loginAttempt = pgTable( "login_attempt", { createdAt: timestamp("created_at", { mode: "string" }).notNull(), - id: uuid("id").notNull().defaultRandom().primaryKey(), + id: uuid().primaryKey().notNull(), userId: uuid("user_id").notNull(), }, (table) => [ - index("login_attempt_user_id_idx").on(table.userId), - index("idx_login_attempt_created_at").using("btree", table.createdAt.desc()), + 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: [users.id], - name: "login_attempt_user_id_fkey", + foreignColumns: [user.id], + name: "fk_8c11c1ba76ed395", }).onDelete("cascade"), ], ); -export const loginHistories = pgTable( +export const loginHistory = pgTable( "login_history", { createdAt: timestamp("created_at", { mode: "string" }).notNull(), - deviceClient: varchar("device_client", { length: 255 }), - deviceDevice: varchar("device_device", { length: 255 }), - deviceIsBot: boolean("device_is_bot").notNull().default(false), - deviceOperatingSystem: varchar("device_operating_system", { length: 255 }), - id: uuid("id").notNull().defaultRandom().primaryKey(), + 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 }), + locationTimeZone: varchar("location_time_zone", { length: 255 }).default(sql`NULL`), userId: uuid("user_id").notNull(), }, (table) => [ - index("login_history_user_id_idx").on(table.userId), - index("idx_login_history_created_at").using("btree", table.userId, table.createdAt.desc()), - index("login_history_ip_address_idx").on(table.ipAddress), + 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: [users.id], - name: "login_history_user_id_fkey", + 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"), ], ); @@ -335,120 +217,223 @@ export const loginHistories = pgTable( export const refreshTokens = pgTable( "refresh_tokens", { - id: integer("id").generatedAlwaysAsIdentity({ name: "refresh_tokens_id_seq" }).primaryKey(), + id: integer().primaryKey().notNull(), refreshToken: varchar("refresh_token", { length: 128 }).notNull(), - username: varchar("username", { length: 255 }).notNull(), - validUntil: timestamp("valid", { mode: "string" }).notNull(), - }, - (table) => [unique("uniq_refresh_token_token").on(table.refreshToken)], -); - -export const verificationTokens = pgTable( - "verification_token", - { - createdAt: timestamp("created_at", { mode: "string" }).notNull(), - id: uuid("id").notNull().defaultRandom().primaryKey(), - purpose: verificationTokenPurposeEnum("purpose").notNull(), - token: varchar("token", { length: 60 }), - userId: uuid("user_id").notNull(), + username: varchar({ length: 255 }).notNull(), + valid: timestamp({ mode: "string" }).notNull(), }, (table) => [ - index("verification_token_user_id_idx").on(table.userId), - index("idx_verification_token_created_at").using("btree", table.createdAt.desc()), - uniqueIndex("unq_verification_token_user_purpose") - .on(table.userId, table.purpose) - .where(sql`token IS NOT NULL`), - foreignKey({ - columns: [table.userId], - foreignColumns: [users.id], - name: "verification_token_user_id_fkey", - }).onDelete("cascade"), + uniqueIndex("uniq_9bace7e1c74f2195").using( + "btree", + table.refreshToken.asc().nullsLast().op("text_ops"), + ), ], ); -// Relations +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(), + 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(), + readingTime: integer("reading_time").default(1), + reliability: varchar({ length: 30 }).default("reliable").notNull(), + sentiment: varchar({ length: 30 }).default("neutral").notNull(), + sourceId: uuid("source_id").notNull(), + title: varchar({ length: 1024 }).notNull(), + tokenStatistics: jsonb("token_statistics"), + transparency: varchar({ length: 30 }).default("medium").notNull(), + 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"))`, + ), + updatedAt: timestamp("updated_at", { mode: "string" }).default(sql`NULL`), + }, + (table) => [ + index("gin_article_categories").using( + "gin", + table.categories.asc().nullsLast().op("array_ops"), + ), + 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( + "btree", + table.publishedAt.desc().nullsFirst().op("timestamp_ops"), + ), + 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")), + foreignKey({ + columns: [table.sourceId], + foreignColumns: [source.id], + name: "fk_23a0e66953c1c61", + }).onDelete("cascade"), + 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[])`, + ), + check( + "chk_article_metadata_json", + sql`(metadata IS NULL) OR (jsonb_typeof(metadata) = ANY (ARRAY['object'::text, 'array'::text]))`, + ), + ], +); -export const sourcesRelations = relations(sources, ({ many }) => ({ - articles: many(articles), - followers: many(followedSources), -})); +export const user = pgTable( + "user", + { + createdAt: timestamp("created_at", { mode: "string" }).notNull(), + email: varchar({ length: 255 }).notNull(), + id: uuid().primaryKey().notNull(), + isConfirmed: boolean("is_confirmed").default(false).notNull(), + isLocked: boolean("is_locked").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`), + }, + (_table) => [ + uniqueIndex("unq_user_email").using("btree", sql`lower((email)::text)`), + check("chk_user_roles_json", sql`jsonb_typeof(roles) = 'array'::text`), + ], +); -export const articlesRelations = relations(articles, ({ one, many }) => ({ - bookmarkLinks: many(bookmarkArticles), - comments: many(comments), - source: one(sources, { - fields: [articles.sourceId], - references: [sources.id], +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)`), + ], +); + +export const bookmarkArticle = pgTable( + "bookmark_article", + { + articleId: uuid("article_id").notNull(), + 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")), + foreignKey({ + columns: [table.bookmarkId], + foreignColumns: [bookmark.id], + name: "fk_6fe2655d92741d25", + }).onDelete("cascade"), + foreignKey({ + columns: [table.articleId], + foreignColumns: [article.id], + name: "fk_6fe2655d7294869c", + }).onDelete("cascade"), + primaryKey({ columns: [table.bookmarkId, table.articleId], name: "bookmark_article_pkey" }), + ], +); + +export const bookmarkRelations = relations(bookmark, ({ one, many }) => ({ + bookmarkArticles: many(bookmarkArticle), + user: one(user, { + fields: [bookmark.userId], + references: [user.id], }), })); -export const appUsersRelations = relations(users, ({ many }) => ({ - bookmarks: many(bookmarks), - comments: many(comments), - followedSources: many(followedSources), - loginAttempts: many(loginAttempts), - loginHistories: many(loginHistories), - verificationTokens: many(verificationTokens), +export const userRelations = relations(user, ({ many }) => ({ + bookmarks: many(bookmark), + comments: many(comment), + followedSources: many(followedSource), + loginAttempts: many(loginAttempt), + loginHistories: many(loginHistory), + verificationTokens: many(verificationToken), })); -export const bookmarksRelations = relations(bookmarks, ({ one, many }) => ({ - articles: many(bookmarkArticles), - user: one(users, { - fields: [bookmarks.userId], - references: [users.id], +export const loginAttemptRelations = relations(loginAttempt, ({ one }) => ({ + user: one(user, { + fields: [loginAttempt.userId], + references: [user.id], }), })); -export const bookmarkArticlesRelations = relations(bookmarkArticles, ({ one }) => ({ - article: one(articles, { - fields: [bookmarkArticles.articleId], - references: [articles.id], - }), - bookmark: one(bookmarks, { - fields: [bookmarkArticles.bookmarkId], - references: [bookmarks.id], +export const loginHistoryRelations = relations(loginHistory, ({ one }) => ({ + user: one(user, { + fields: [loginHistory.userId], + references: [user.id], }), })); -export const commentsRelations = relations(comments, ({ one }) => ({ - article: one(articles, { - fields: [comments.articleId], - references: [articles.id], - }), - user: one(users, { - fields: [comments.userId], - references: [users.id], +export const verificationTokenRelations = relations(verificationToken, ({ one }) => ({ + user: one(user, { + fields: [verificationToken.userId], + references: [user.id], }), })); -export const followedSourcesRelations = relations(followedSources, ({ one }) => ({ - follower: one(users, { - fields: [followedSources.followerId], - references: [users.id], +export const followedSourceRelations = relations(followedSource, ({ one }) => ({ + source: one(source, { + fields: [followedSource.sourceId], + references: [source.id], }), - source: one(sources, { - fields: [followedSources.sourceId], - references: [sources.id], + user: one(user, { + fields: [followedSource.followerId], + references: [user.id], }), })); -export const loginAttemptsRelations = relations(loginAttempts, ({ one }) => ({ - user: one(users, { - fields: [loginAttempts.userId], - references: [users.id], +export const sourceRelations = relations(source, ({ many }) => ({ + articles: many(article), + followedSources: many(followedSource), +})); + +export const commentRelations = relations(comment, ({ one }) => ({ + article: one(article, { + fields: [comment.articleId], + references: [article.id], + }), + user: one(user, { + fields: [comment.userId], + references: [user.id], }), })); -export const loginHistoriesRelations = relations(loginHistories, ({ one }) => ({ - user: one(users, { - fields: [loginHistories.userId], - references: [users.id], +export const articleRelations = relations(article, ({ one, many }) => ({ + bookmarkArticles: many(bookmarkArticle), + comments: many(comment), + source: one(source, { + fields: [article.sourceId], + references: [source.id], }), })); -export const verificationTokensRelations = relations(verificationTokens, ({ one }) => ({ - user: one(users, { - fields: [verificationTokens.userId], - references: [users.id], +export const bookmarkArticleRelations = relations(bookmarkArticle, ({ one }) => ({ + article: one(article, { + fields: [bookmarkArticle.articleId], + references: [article.id], + }), + bookmark: one(bookmark, { + fields: [bookmarkArticle.bookmarkId], + references: [bookmark.id], }), })); diff --git a/packages/db/src/utils/pagination.ts b/packages/db/src/utils/pagination.ts index a740bc2..571bc61 100644 --- a/packages/db/src/utils/pagination.ts +++ b/packages/db/src/utils/pagination.ts @@ -1,5 +1,11 @@ import { Buffer } from "node:buffer"; +import { + PAGINATION_DEFAULT_LIMIT, + PAGINATION_DEFAULT_PAGE, + PAGINATION_MAX_LIMIT, +} from "@/constants"; + export type SortDirection = "asc" | "desc"; export interface PageRequest { @@ -27,27 +33,23 @@ export interface PaginationMeta { hasNext: boolean; } -const DEFAULT_PAGE = 1; -const DEFAULT_LIMIT = 5; -const MAX_LIMIT = 100; - export function createPageState(request: PageRequest = {}): PageState { const page = Number.isFinite(request.page) && (request.page ?? 0) > 0 ? Math.trunc(request.page!) - : DEFAULT_PAGE; + : PAGINATION_DEFAULT_PAGE; let limit = Number.isFinite(request.limit) && (request.limit ?? 0) > 0 ? Math.trunc(request.limit!) - : DEFAULT_LIMIT; + : PAGINATION_DEFAULT_LIMIT; - if (limit < DEFAULT_LIMIT) { - limit = DEFAULT_LIMIT; + if (limit < PAGINATION_DEFAULT_LIMIT) { + limit = PAGINATION_DEFAULT_LIMIT; } - if (limit > MAX_LIMIT) { - limit = MAX_LIMIT; + if (limit > PAGINATION_MAX_LIMIT) { + limit = PAGINATION_MAX_LIMIT; } const cursor = request.cursor ?? null; @@ -92,28 +94,3 @@ export function decodeCursor(cursor?: string | null): CursorPayload | null { return null; } } - -export function buildPaginationResult>( - rows: T[], - page: PageState, - keyset: { id: string; date?: string | null }, -): { data: T[]; pagination: PaginationMeta } { - const hasNext = rows.length > page.limit; - const data = hasNext ? rows.slice(0, page.limit) : rows; - - let cursor: string | null = null; - if (data.length > 0) { - const lastRow = data[data.length - 1]; - cursor = encodeCursor(lastRow, keyset); - } - - return { - data, - pagination: { - current: page.page, - cursor, - hasNext, - limit: page.limit, - }, - }; -} diff --git a/packages/encryption/package.json b/packages/encryption/package.json new file mode 100644 index 0000000..01ecad0 --- /dev/null +++ b/packages/encryption/package.json @@ -0,0 +1,9 @@ +{ + "main": "src/index.ts", + "name": "@basango/encryption", + "private": true, + "scripts": { + "clean": "rm -rf .turbo node_modules", + "typecheck": "tsc --noEmit" + } +} diff --git a/packages/encryption/src/index.ts b/packages/encryption/src/index.ts new file mode 100644 index 0000000..1176e75 --- /dev/null +++ b/packages/encryption/src/index.ts @@ -0,0 +1,76 @@ +import crypto from "node:crypto"; + +import { createEnvAccessor } from "@devscast/config"; + +export const env = createEnvAccessor(["BASANGO_ENCRYPTION_KEY"] as const); + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 16; +const AUTH_TAG_LENGTH = 16; + +function getKey(): Buffer { + const key = env("BASANGO_ENCRYPTION_KEY"); + + if (Buffer.from(key, "hex").length !== 32) { + throw new Error("BASANGO_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)."); + } + return Buffer.from(key, "hex"); +} + +/** + * Encrypts a plaintext string using AES-256-GCM. + * @param text The plaintext string to encrypt. + * @returns A string containing the IV, auth tag, and encrypted text, concatenated and base64 encoded. + */ +export function encrypt(text: string): string { + const key = getKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(text, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const authTag = cipher.getAuthTag(); + + // Concatenate IV, auth tag, and encrypted data + const encryptedPayload = Buffer.concat([iv, authTag, Buffer.from(encrypted, "hex")]).toString( + "base64", + ); + + return encryptedPayload; +} + +/** + * Decrypts an AES-256-GCM encrypted string. + * @param encryptedPayload The base64 encoded string containing the IV, auth tag, and encrypted text. + * @returns The original plaintext string. + */ +export function decrypt(encryptedPayload: string): string { + const key = getKey(); + const dataBuffer = Buffer.from(encryptedPayload, "base64"); + + // Extract IV, auth tag, and encrypted data + const iv = dataBuffer.subarray(0, IV_LENGTH); + const authTag = dataBuffer.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const encryptedText = dataBuffer.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedText.toString("hex"), "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; +} + +export function hash(str: string): string { + return crypto.createHash("sha256").update(str).digest("hex"); +} + +export function md5(str: string): string { + return crypto.createHash("md5").update(str).digest("hex"); +} + +export function generateRandomBytes(size: number): string { + return crypto.randomBytes(size).toString("hex"); +} diff --git a/packages/encryption/tsconfig.json b/packages/encryption/tsconfig.json new file mode 100644 index 0000000..c390cc9 --- /dev/null +++ b/packages/encryption/tsconfig.json @@ -0,0 +1,5 @@ +{ + "exclude": ["node_modules"], + "extends": "@basango/tsconfig/base.json", + "include": ["src/**/*"] +} diff --git a/packages/logger/package.json b/packages/logger/package.json index e8efbb2..66179b5 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -3,9 +3,6 @@ "pino": "^10.1.0", "pino-pretty": "^13.1.2" }, - "devDependencies": { - "typescript": "catalog:" - }, "main": "src/index.ts", "name": "@basango/logger", "private": true, diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index 9b2c18f..38540e7 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -1,9 +1,12 @@ +import { createEnvAccessor } from "@devscast/config"; import pino from "pino"; +const env = createEnvAccessor(["LOG_LEVEL", "NODE_ENV"] as const); + export const logger = pino({ - level: process.env.LOG_LEVEL || "debug", + level: env("LOG_LEVEL", { default: "info" }), // Use pretty printing in development, structured JSON in production - ...(process.env.NODE_ENV !== "production" && { + ...(env("NODE_ENV") !== "production" && { transport: { options: { colorize: true, diff --git a/packages/ui/package.json b/packages/ui/package.json index 02bfe51..a594233 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -15,11 +15,9 @@ "@basango/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.1.11", "@turbo/gen": "^2.5.5", - "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "tailwindcss": "^4.1.11", - "typescript": "catalog:" + "tailwindcss": "^4.1.11" }, "exports": { "./components/*": "./src/components/*.tsx",