Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/routers/tg/users.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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<string>`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 {
Expand All @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions tests/idp-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
})
Expand Down
89 changes: 89 additions & 0 deletions tests/tg-users-creation-history.test.ts
Original file line number Diff line number Diff line change
@@ -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: <T>(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<unknown> }

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<string, unknown>
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<string, { sql: string }>
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()
})
})
Loading