diff --git a/apps/web/package.json b/apps/web/package.json index 67c93b7..d096f47 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@hookform/resolvers": "^5.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "drizzle-orm": "^0.36.0", "geist": "^1.7.0", "lucide-react": "^1.14.0", "next": "^16.0.0", @@ -28,6 +29,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.75.0", + "server-only": "^0.0.1", "shadcn": "^4.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", diff --git a/apps/web/src/lib/db.ts b/apps/web/src/lib/db.ts new file mode 100644 index 0000000..46541f0 --- /dev/null +++ b/apps/web/src/lib/db.ts @@ -0,0 +1,8 @@ +import "server-only"; +import { createClient, type DB } from "@cmbot/db"; +import { env } from "@/env"; + +const { db, pool } = createClient(env.DATABASE_URL); + +export { db, pool }; +export type { DB }; diff --git a/apps/web/src/lib/logger.ts b/apps/web/src/lib/logger.ts new file mode 100644 index 0000000..5d15ae4 --- /dev/null +++ b/apps/web/src/lib/logger.ts @@ -0,0 +1,9 @@ +import "server-only"; +import pino from "pino"; + +export const logger = pino({ + level: process.env.NODE_ENV === "production" ? "info" : "debug", + ...(process.env.NODE_ENV !== "production" + ? { transport: { target: "pino-pretty", options: { colorize: true } } } + : {}), +}); diff --git a/apps/web/src/lib/notify.ts b/apps/web/src/lib/notify.ts new file mode 100644 index 0000000..f4e3ff8 --- /dev/null +++ b/apps/web/src/lib/notify.ts @@ -0,0 +1,15 @@ +import "server-only"; +import { sql } from "drizzle-orm"; +import { db } from "./db"; + +export type BotCommand = + | { type: "account.start_pairing"; accountId: string } + | { type: "account.unpair"; accountId: string } + | { type: "account.sync_groups"; accountId: string } + | { type: "group.send_test"; groupId: string; text: string } + | { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }; + +export async function pgNotifyBot(cmd: BotCommand): Promise { + const json = JSON.stringify(cmd); + await db.execute(sql`SELECT pg_notify('bot.command', ${json})`); +} diff --git a/apps/web/src/lib/operator.ts b/apps/web/src/lib/operator.ts new file mode 100644 index 0000000..8062676 --- /dev/null +++ b/apps/web/src/lib/operator.ts @@ -0,0 +1,16 @@ +import "server-only"; +import { db } from "./db"; + +/** + * Returns the single seeded operator row. Since the app has no auth, + * every action is attributed to this operator. + */ +export async function getSeededOperator() { + const op = await db.query.operators.findFirst({ + orderBy: (o, { asc }) => [asc(o.createdAt)], + }); + if (!op) { + throw new Error("No operator row seeded. Run scripts/db.sh seed."); + } + return op; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6739ad..7b97a78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + drizzle-orm: + specifier: ^0.36.0 + version: 0.36.4(@types/pg@8.20.0)(@types/react@19.2.14)(pg@8.20.0)(react@19.2.6) geist: specifier: ^1.7.0 version: 1.7.0(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) @@ -126,6 +129,9 @@ importers: react-hook-form: specifier: ^7.75.0 version: 7.75.0(react@19.2.6) + server-only: + specifier: ^0.0.1 + version: 0.0.1 shadcn: specifier: ^4.7.0 version: 4.7.0(@types/node@22.19.18)(typescript@5.9.3) @@ -3963,6 +3969,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -8005,6 +8014,8 @@ snapshots: transitivePeerDependencies: - supports-color + server-only@0.0.1: {} + set-blocking@2.0.0: {} set-cookie-parser@3.1.0: {}