diff --git a/src/routers/tg/users.ts b/src/routers/tg/users.ts index 64cf28f..219d777 100644 --- a/src/routers/tg/users.ts +++ b/src/routers/tg/users.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm" +import { count, eq, sql } from "drizzle-orm" import { z } from "zod" import { DB, SCHEMA } from "@/db" import { botReadOrDashboard, dashboard, SCOPE } from "@/idp/policies" @@ -11,6 +11,20 @@ import { decryptUser, encryptUser, TgUserSchema, userCipher } from "@/utils/user const s = SCHEMA.TG const upsertSet = upsertMultipleSetSql(s.users, ["firstName", "lastName", "username", "langCode", "isBot"]) +/** The calendar day a user was first stored, in the association's time zone ("2026-10-11"). */ +const creationDay = sql`to_char(${s.users.createdAt} AT TIME ZONE 'Europe/Rome', 'YYYY-MM-DD')` + +const CreationHistorySchema = z.union([ + z.object({ + days: z.array(z.object({ date: z.string(), count: z.number().int().nonnegative() })), + error: z.null(), + }), + z.object({ + error: z.literal("INTERNAL_SERVER_ERROR"), + days: z.null().optional(), + }), +]) + export default createTRPCRouter({ getAll: policy(dashboard("tg:users:read")).query(async () => { try { @@ -31,6 +45,27 @@ export default createTRPCRouter({ } }), + /** + * How many Telegram users were first stored on each day, from `created_at`, oldest day first. Days without new users + * are omitted. Only dates and counts leave the database: the dashboard charts user growth without loading or + * decrypting the users themselves. New procedures take tokens only (no legacy anonymous path). + */ + getCreationHistory: policy(dashboard("tg:users:read"), { tokenOnly: true }) + .output(CreationHistorySchema) + .query(async () => { + try { + const days = await DB.select({ date: creationDay, count: count() }) + .from(s.users) + .groupBy(creationDay) + .orderBy(creationDay) + + return { days, error: null } + } catch (error) { + logger.error(error, "error while counting telegram users by creation day") + return { error: "INTERNAL_SERVER_ERROR" as const } + } + }), + get: policy(botReadOrDashboard("tg:users:read")) .input(z.object({ userId: z.number() })) .output( diff --git a/tests/idp-router.test.ts b/tests/idp-router.test.ts index 52fee81..52d18a1 100644 --- a/tests/idp-router.test.ts +++ b/tests/idp-router.test.ts @@ -84,6 +84,10 @@ describe("app router", () => { policy: { service: { scope: "backend:tg:audit" } }, tokenOnly: true, }) + expect(policyOf("tg.users.getCreationHistory")).toMatchObject({ + policy: { user: { scope: "backend:admin", permission: "tg:users:read" } }, + tokenOnly: true, + }) expect(policyOf("tg.grants.create")?.policy).toEqual({ user: { scope: "backend:admin", permission: "tg:grants:manage" }, }) diff --git a/tests/tg-users-creation-history.test.ts b/tests/tg-users-creation-history.test.ts new file mode 100644 index 0000000..173838d --- /dev/null +++ b/tests/tg-users-creation-history.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const state = vi.hoisted(() => { + const users = { createdAt: Symbol("users.createdAt") } + const rows = [ + { date: "2026-10-10", count: 3 }, + { date: "2026-10-11", count: 1 }, + ] + const orderBy = vi.fn(async () => rows) + const groupBy = vi.fn(() => ({ orderBy })) + const from = vi.fn(() => ({ groupBy })) + const select = vi.fn((_fields: unknown) => ({ from })) + // Recorded at import, so `vi.clearAllMocks` does not reset it. + const policies: unknown[] = [] + const error = vi.fn() + + return { error, from, groupBy, orderBy, policies, rows, select, users } +}) + +vi.mock("drizzle-orm", () => ({ + count: vi.fn(() => "count(*)"), + eq: vi.fn(), + sql: vi.fn((strings: TemplateStringsArray) => ({ sql: strings.join("?") })), +})) +vi.mock("@/db", () => ({ DB: { select: state.select }, SCHEMA: { TG: { users: state.users } } })) +vi.mock("@/logger", () => ({ logger: { error: state.error } })) +vi.mock("@/utils/db", () => ({ upsertMultipleSetSql: vi.fn() })) +vi.mock("@/utils/cipher", () => ({ DecryptError: class DecryptError extends Error {} })) +vi.mock("@/utils/users", () => ({ decryptUser: vi.fn(), encryptUser: vi.fn(), TgUserSchema: {}, userCipher: {} })) +vi.mock("@/idp/policies", () => ({ + botReadOrDashboard: vi.fn(), + dashboard: (permission: string) => ({ user: { permission } }), + SCOPE: { tgIngest: "tg:ingest" }, +})) +vi.mock("@/trpc", () => { + const procedure = { + input: () => procedure, + output: () => procedure, + query: (handler: unknown) => ({ handler }), + mutation: (handler: unknown) => ({ handler }), + } + return { + createTRPCRouter: (router: T) => router, + policy: (rule: unknown, options: unknown = {}) => { + state.policies.push({ rule, options }) + return procedure + }, + } +}) + +const { default: usersRouter } = await import("@/routers/tg/users") +const history = usersRouter.getCreationHistory as unknown as { handler: () => Promise } + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("getCreationHistory", () => { + it("is limited to token callers who can read Telegram users", () => { + expect(state.policies).toContainEqual({ + rule: { user: { permission: "tg:users:read" } }, + options: { tokenOnly: true }, + }) + }) + + it("returns only dates and counts, grouped and ordered by creation day", async () => { + await expect(history.handler()).resolves.toEqual({ days: state.rows, error: null }) + + const fields = state.select.mock.calls[0]?.[0] as Record + expect(Object.keys(fields)).toEqual(["date", "count"]) + expect(state.from).toHaveBeenCalledWith(state.users) + expect(state.groupBy).toHaveBeenCalledWith(fields.date) + expect(state.orderBy).toHaveBeenCalledWith(fields.date) + }) + + it("buckets by the Europe/Rome calendar day", async () => { + await history.handler() + + const fields = state.select.mock.calls[0]?.[0] as Record + expect(fields.date.sql).toBe("to_char(? AT TIME ZONE 'Europe/Rome', 'YYYY-MM-DD')") + }) + + it("reports a database failure without throwing", async () => { + state.orderBy.mockRejectedValueOnce(new Error("connection lost")) + + await expect(history.handler()).resolves.toEqual({ error: "INTERNAL_SERVER_ERROR" }) + expect(state.error).toHaveBeenCalled() + }) +})