Skip to content

Commit 69e7c3a

Browse files
committed
feat: add stats_requests table and logging for stats requests
1 parent 8e03674 commit 69e7c3a

5 files changed

Lines changed: 296 additions & 0 deletions

File tree

drizzle/0002_stats_requests.sql

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
CREATE TABLE `stats_requests` (
2+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3+
`username` text NOT NULL,
4+
`url` text NOT NULL,
5+
`created_at` integer
6+
);
7+
--> statement-breakpoint
8+
CREATE UNIQUE INDEX `uq_stats_request_url` ON `stats_requests` (`url`);--> statement-breakpoint
9+
CREATE TABLE `visitor_logs` (
10+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
11+
`username` text NOT NULL,
12+
`ip_hash` text NOT NULL,
13+
`visit_date` text NOT NULL,
14+
`created_at` integer
15+
);
16+
--> statement-breakpoint
17+
CREATE UNIQUE INDEX `uq_visitor_log` ON `visitor_logs` (`username`,`ip_hash`,`visit_date`);

drizzle/meta/0002_snapshot.json

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
{
2+
"version": "6",
3+
"dialect": "sqlite",
4+
"id": "471a2e0e-677b-4b1c-a4fd-aaae0ec78f47",
5+
"prevId": "6959e226-bad1-4ec1-8cc8-60a3b61482a0",
6+
"tables": {
7+
"badges": {
8+
"name": "badges",
9+
"columns": {
10+
"username": {
11+
"name": "username",
12+
"type": "text",
13+
"primaryKey": true,
14+
"notNull": true,
15+
"autoincrement": false
16+
},
17+
"visitors": {
18+
"name": "visitors",
19+
"type": "integer",
20+
"primaryKey": false,
21+
"notNull": true,
22+
"autoincrement": false,
23+
"default": 0
24+
},
25+
"repositories": {
26+
"name": "repositories",
27+
"type": "integer",
28+
"primaryKey": false,
29+
"notNull": false,
30+
"autoincrement": false
31+
},
32+
"organization": {
33+
"name": "organization",
34+
"type": "integer",
35+
"primaryKey": false,
36+
"notNull": false,
37+
"autoincrement": false
38+
},
39+
"languages": {
40+
"name": "languages",
41+
"type": "integer",
42+
"primaryKey": false,
43+
"notNull": false,
44+
"autoincrement": false
45+
},
46+
"followers": {
47+
"name": "followers",
48+
"type": "integer",
49+
"primaryKey": false,
50+
"notNull": false,
51+
"autoincrement": false
52+
},
53+
"total_stars": {
54+
"name": "total_stars",
55+
"type": "integer",
56+
"primaryKey": false,
57+
"notNull": false,
58+
"autoincrement": false
59+
},
60+
"total_contributors": {
61+
"name": "total_contributors",
62+
"type": "integer",
63+
"primaryKey": false,
64+
"notNull": false,
65+
"autoincrement": false
66+
},
67+
"total_commits": {
68+
"name": "total_commits",
69+
"type": "integer",
70+
"primaryKey": false,
71+
"notNull": false,
72+
"autoincrement": false
73+
},
74+
"total_code_reviews": {
75+
"name": "total_code_reviews",
76+
"type": "integer",
77+
"primaryKey": false,
78+
"notNull": false,
79+
"autoincrement": false
80+
},
81+
"total_issues": {
82+
"name": "total_issues",
83+
"type": "integer",
84+
"primaryKey": false,
85+
"notNull": false,
86+
"autoincrement": false
87+
},
88+
"total_pull_requests": {
89+
"name": "total_pull_requests",
90+
"type": "integer",
91+
"primaryKey": false,
92+
"notNull": false,
93+
"autoincrement": false
94+
},
95+
"total_joined_years": {
96+
"name": "total_joined_years",
97+
"type": "integer",
98+
"primaryKey": false,
99+
"notNull": false,
100+
"autoincrement": false
101+
},
102+
"updated_at": {
103+
"name": "updated_at",
104+
"type": "integer",
105+
"primaryKey": false,
106+
"notNull": false,
107+
"autoincrement": false
108+
}
109+
},
110+
"indexes": {},
111+
"foreignKeys": {},
112+
"compositePrimaryKeys": {},
113+
"uniqueConstraints": {},
114+
"checkConstraints": {}
115+
},
116+
"stats_requests": {
117+
"name": "stats_requests",
118+
"columns": {
119+
"id": {
120+
"name": "id",
121+
"type": "integer",
122+
"primaryKey": true,
123+
"notNull": true,
124+
"autoincrement": true
125+
},
126+
"username": {
127+
"name": "username",
128+
"type": "text",
129+
"primaryKey": false,
130+
"notNull": true,
131+
"autoincrement": false
132+
},
133+
"url": {
134+
"name": "url",
135+
"type": "text",
136+
"primaryKey": false,
137+
"notNull": true,
138+
"autoincrement": false
139+
},
140+
"created_at": {
141+
"name": "created_at",
142+
"type": "integer",
143+
"primaryKey": false,
144+
"notNull": false,
145+
"autoincrement": false
146+
}
147+
},
148+
"indexes": {
149+
"uq_stats_request_url": {
150+
"name": "uq_stats_request_url",
151+
"columns": [
152+
"url"
153+
],
154+
"isUnique": true
155+
}
156+
},
157+
"foreignKeys": {},
158+
"compositePrimaryKeys": {},
159+
"uniqueConstraints": {},
160+
"checkConstraints": {}
161+
},
162+
"visitor_logs": {
163+
"name": "visitor_logs",
164+
"columns": {
165+
"id": {
166+
"name": "id",
167+
"type": "integer",
168+
"primaryKey": true,
169+
"notNull": true,
170+
"autoincrement": true
171+
},
172+
"username": {
173+
"name": "username",
174+
"type": "text",
175+
"primaryKey": false,
176+
"notNull": true,
177+
"autoincrement": false
178+
},
179+
"ip_hash": {
180+
"name": "ip_hash",
181+
"type": "text",
182+
"primaryKey": false,
183+
"notNull": true,
184+
"autoincrement": false
185+
},
186+
"visit_date": {
187+
"name": "visit_date",
188+
"type": "text",
189+
"primaryKey": false,
190+
"notNull": true,
191+
"autoincrement": false
192+
},
193+
"created_at": {
194+
"name": "created_at",
195+
"type": "integer",
196+
"primaryKey": false,
197+
"notNull": false,
198+
"autoincrement": false
199+
}
200+
},
201+
"indexes": {
202+
"uq_visitor_log": {
203+
"name": "uq_visitor_log",
204+
"columns": [
205+
"username",
206+
"ip_hash",
207+
"visit_date"
208+
],
209+
"isUnique": true
210+
}
211+
},
212+
"foreignKeys": {},
213+
"compositePrimaryKeys": {},
214+
"uniqueConstraints": {},
215+
"checkConstraints": {}
216+
}
217+
},
218+
"views": {},
219+
"enums": {},
220+
"_meta": {
221+
"schemas": {},
222+
"tables": {},
223+
"columns": {}
224+
},
225+
"internal": {
226+
"indexes": {}
227+
}
228+
}

drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
"when": 1740369600000,
1616
"tag": "0001_visitor_logs",
1717
"breakpoints": true
18+
},
19+
{
20+
"idx": 2,
21+
"version": "6",
22+
"when": 1775284238524,
23+
"tag": "0002_stats_requests",
24+
"breakpoints": true
1825
}
1926
]
2027
}

src/controllers/stats.controller.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { GitHubClient } from '../utils/github-client.js';
33
import { CardRenderer } from '../components/card-renderer.js';
44
import { createLogger } from '../common/logger.js';
55
import sharp from 'sharp';
6+
import { db } from '../db/index.js';
7+
import { statsRequests } from '../db/schema.js';
68

79
const logger = createLogger({ controller: 'StatsController' });
810

@@ -70,6 +72,33 @@ export class StatsController {
7072
return res.status(400).send('Username is required');
7173
}
7274

75+
const normalizedParams = Object.entries(req.query)
76+
.flatMap(([key, value]) => {
77+
if (value === undefined || value === null) return [] as Array<[string, string]>;
78+
if (Array.isArray(value)) {
79+
return value.map((item) => [key, String(item)] as [string, string]);
80+
}
81+
return [[key, String(value)] as [string, string]];
82+
})
83+
.sort(([aKey, aVal], [bKey, bVal]) => {
84+
const keyCompare = aKey.localeCompare(bKey);
85+
return keyCompare !== 0 ? keyCompare : aVal.localeCompare(bVal);
86+
});
87+
88+
const queryString = new URLSearchParams(normalizedParams).toString();
89+
const normalizedEndpoint = queryString ? `${req.path}?${queryString}` : req.path;
90+
91+
db.insert(statsRequests)
92+
.values({ username, url: normalizedEndpoint, created_at: Date.now() })
93+
.onConflictDoUpdate({
94+
target: statsRequests.url,
95+
set: {
96+
username,
97+
created_at: Date.now(),
98+
},
99+
})
100+
.catch((err: Error) => logger.error('Failed to log stats request', err, { username, normalizedEndpoint }));
101+
73102
// Backward compatibility: convert show_avatar to avatar_mode
74103
let finalAvatarMode = avatar_mode as string;
75104
if (show_avatar === 'true') {

src/db/schema.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";
22

3+
export const statsRequests = sqliteTable(
4+
"stats_requests",
5+
{
6+
id: integer("id").primaryKey({ autoIncrement: true }),
7+
username: text("username").notNull(),
8+
url: text("url").notNull(),
9+
created_at: integer("created_at"),
10+
},
11+
(table) => {
12+
return {
13+
uqStatsRequestUrl: uniqueIndex("uq_stats_request_url").on(table.url),
14+
};
15+
},
16+
);
17+
318
export const visitorLogs = sqliteTable(
419
"visitor_logs",
520
{

0 commit comments

Comments
 (0)