From fbd809ba1c87d2d9e060117e4ce49b39ce8316fd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 00:07:42 +0000 Subject: [PATCH 1/2] feat(tg): count Telegram users per creation day Add `tg.users.getCreationHistory`, a read-only aggregate over `created_at` in `tg.users`: one `{ date, count }` row per Europe/Rome calendar day with new users, oldest first. Only dates and counts leave the database, so the admin dashboard can chart user growth without loading or decrypting every user. It uses the same `tg:users:read` dashboard policy as `getAll`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016Wcgyqa8nfsrm4SSd283AF --- src/routers/tg/users.ts | 37 +++++++++++- tests/tg-users-creation-history.test.ts | 79 +++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 tests/tg-users-creation-history.test.ts diff --git a/src/routers/tg/users.ts b/src/routers/tg/users.ts index 64cf28f..a458289 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. + */ + getCreationHistory: policy(dashboard("tg:users:read")) + .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/tg-users-creation-history.test.ts b/tests/tg-users-creation-history.test.ts new file mode 100644 index 0000000..c1747c6 --- /dev/null +++ b/tests/tg-users-creation-history.test.ts @@ -0,0 +1,79 @@ +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) => { + state.policies.push(rule) + 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 dashboard users who can read Telegram users", () => { + expect(state.policies).toContainEqual({ user: { permission: "tg:users:read" } }) + }) + + 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("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() + }) +}) From 7f4b9b277b59e7f3bbf36d72ccc8f0525a0eda13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 06:51:12 +0000 Subject: [PATCH 2/2] fix(tg): make the creation history token-only and pin its day bucketing `tg.users.getCreationHistory` is new, so like every procedure added for the IdP it refuses the legacy anonymous path (`tokenOnly`). The router test asserts its policy, and the procedure test asserts that days are bucketed by the Europe/Rome calendar day. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016Wcgyqa8nfsrm4SSd283AF --- src/routers/tg/users.ts | 4 ++-- tests/idp-router.test.ts | 4 ++++ tests/tg-users-creation-history.test.ts | 18 ++++++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/routers/tg/users.ts b/src/routers/tg/users.ts index a458289..219d777 100644 --- a/src/routers/tg/users.ts +++ b/src/routers/tg/users.ts @@ -48,9 +48,9 @@ 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. + * decrypting the users themselves. New procedures take tokens only (no legacy anonymous path). */ - getCreationHistory: policy(dashboard("tg:users:read")) + getCreationHistory: policy(dashboard("tg:users:read"), { tokenOnly: true }) .output(CreationHistorySchema) .query(async () => { try { 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 index c1747c6..173838d 100644 --- a/tests/tg-users-creation-history.test.ts +++ b/tests/tg-users-creation-history.test.ts @@ -41,8 +41,8 @@ vi.mock("@/trpc", () => { } return { createTRPCRouter: (router: T) => router, - policy: (rule: unknown) => { - state.policies.push(rule) + policy: (rule: unknown, options: unknown = {}) => { + state.policies.push({ rule, options }) return procedure }, } @@ -56,8 +56,11 @@ beforeEach(() => { }) describe("getCreationHistory", () => { - it("is limited to dashboard users who can read Telegram users", () => { - expect(state.policies).toContainEqual({ user: { permission: "tg:users:read" } }) + 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 () => { @@ -70,6 +73,13 @@ describe("getCreationHistory", () => { 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"))