diff --git a/.a2a/agent.dsl.json b/.a2a/agent.dsl.json index b3059cf..0901457 100644 --- a/.a2a/agent.dsl.json +++ b/.a2a/agent.dsl.json @@ -93,7 +93,23 @@ "wants_cp_jwt": false, "platform_resources": { "memory": null, - "databases": [] + "databases": [ + { + "name": "app", + "engine": "postgres", + "provider": "neon", + "scope": "user", + "branch": "main", + "access_mode": "read_write", + "env": { + "url": "DATABASE_URL" + }, + "migrations": { + "path": "db/migrations" + }, + "scale_to_zero": true + } + ] }, "apt_packages": [] }, diff --git a/a2a.yaml b/a2a.yaml index 71514b4..8eca598 100644 --- a/a2a.yaml +++ b/a2a.yaml @@ -8,17 +8,19 @@ expose: public: true runtime: lifecycle: warm -# Optional platform resources: -# resources: -# memory: -# tiers: [kv, vector] -# namespace: robertseares -# databases: -# - name: app -# provider: neon -# engine: postgres -# env: -# url: DATABASE_URL +resources: + databases: + - name: app + provider: neon + engine: postgres + scope: user + branch: main + access_mode: read_write + env: + url: DATABASE_URL + migrations: + path: db/migrations + scale_to_zero: true frontend: type: server-rendered diff --git a/db/migrations/001_init.sql b/db/migrations/001_init.sql new file mode 100644 index 0000000..c25c472 --- /dev/null +++ b/db/migrations/001_init.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS robertseares_page_loads ( + id BIGSERIAL PRIMARY KEY, + path TEXT NOT NULL, + locale TEXT, + referrer TEXT, + user_agent TEXT, + viewport JSONB NOT NULL DEFAULT '{}'::jsonb, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS robertseares_page_loads_created_at_idx + ON robertseares_page_loads (created_at DESC); + +CREATE INDEX IF NOT EXISTS robertseares_page_loads_path_created_at_idx + ON robertseares_page_loads (path, created_at DESC); diff --git a/frontend/app/(en)/layout.jsx b/frontend/app/(en)/layout.jsx index 570c282..bca0d31 100644 --- a/frontend/app/(en)/layout.jsx +++ b/frontend/app/(en)/layout.jsx @@ -1,4 +1,5 @@ import "../globals.css"; +import { PageLoadTracker } from "../PageLoadTracker.jsx"; import { baseMetadata } from "../seo.js"; export const metadata = baseMetadata; @@ -6,7 +7,10 @@ export const metadata = baseMetadata; export default function EnglishLayout({ children }) { return ( - {children} + + + {children} + ); } diff --git a/frontend/app/PageLoadTracker.jsx b/frontend/app/PageLoadTracker.jsx new file mode 100644 index 0000000..bd64fb9 --- /dev/null +++ b/frontend/app/PageLoadTracker.jsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect } from "react"; + +export function PageLoadTracker({ locale }) { + useEffect(() => { + const payload = { + path: window.location.pathname, + locale, + referrer: document.referrer || null, + viewport: { + width: window.innerWidth, + height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio || 1, + }, + metadata: { + language: navigator.language || null, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null, + }, + }; + const body = JSON.stringify(payload); + + if (navigator.sendBeacon) { + const blob = new Blob([body], { type: "application/json" }); + navigator.sendBeacon("/api/page-load", blob); + return; + } + + fetch("/api/page-load", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + keepalive: true, + }).catch(() => {}); + }, [locale]); + + return null; +} diff --git a/frontend/app/api/page-load/route.js b/frontend/app/api/page-load/route.js new file mode 100644 index 0000000..7575730 --- /dev/null +++ b/frontend/app/api/page-load/route.js @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import pg from "pg"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const { Pool } = pg; + +function pool() { + if (!process.env.DATABASE_URL) { + return null; + } + globalThis.__robertsearesPageLoadPool ||= new Pool({ + connectionString: process.env.DATABASE_URL, + max: 2, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 10_000, + }); + return globalThis.__robertsearesPageLoadPool; +} + +function text(value, maxLength) { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed.slice(0, maxLength) : null; +} + +function object(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +export async function POST(request) { + const database = pool(); + if (!database) { + return new NextResponse(null, { status: 202 }); + } + + try { + const body = await request.json(); + const path = text(body.path, 512) || "/"; + const locale = text(body.locale, 16); + const referrer = text(body.referrer, 2048); + const userAgent = text(request.headers.get("user-agent"), 2048); + const viewport = object(body.viewport); + const metadata = { + ...object(body.metadata), + forwardedFor: text(request.headers.get("x-forwarded-for"), 2048), + country: + text(request.headers.get("cf-ipcountry"), 16) + || text(request.headers.get("x-vercel-ip-country"), 16) + || text(request.headers.get("x-country-code"), 16), + }; + + await database.query( + `INSERT INTO robertseares_page_loads + (path, locale, referrer, user_agent, viewport, metadata) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)`, + [path, locale, referrer, userAgent, JSON.stringify(viewport), JSON.stringify(metadata)], + ); + } catch (error) { + console.warn("page load tracking failed", error); + } + + return new NextResponse(null, { status: 204 }); +} + +export function GET() { + return NextResponse.json({ ok: true }); +} diff --git a/frontend/app/pt/layout.jsx b/frontend/app/pt/layout.jsx index 653c544..3200a92 100644 --- a/frontend/app/pt/layout.jsx +++ b/frontend/app/pt/layout.jsx @@ -1,4 +1,5 @@ import "../globals.css"; +import { PageLoadTracker } from "../PageLoadTracker.jsx"; import { baseMetadata } from "../seo.js"; export const metadata = { @@ -16,7 +17,10 @@ export const metadata = { export default function PortugueseLayout({ children }) { return ( - {children} + + + {children} + ); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a29a89c..15c80ba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "next": "^15.3.0", + "pg": "^8.13.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -923,6 +924,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -1407,6 +1474,95 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1442,6 +1598,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1536,6 +1731,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -1585,6 +1789,15 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/frontend/package.json b/frontend/package.json index b169e3a..57708dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "next": "^15.3.0", + "pg": "^8.13.1", "react": "^19.0.0", "react-dom": "^19.0.0" },