Skip to content

Commit 2b6776e

Browse files
committed
feat(backend): Add a UserAnalytics with custom range (This is so cool)
1 parent cf98549 commit 2b6776e

14 files changed

Lines changed: 157 additions & 21 deletions

File tree

analytics/Dockerfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
FROM maven:3.9.4-eclipse-temurin-21 AS builder
2+
WORKDIR /build
3+
COPY pom.xml .
4+
COPY src ./src
5+
RUN mvn -e -DskipTests clean package
6+
7+
FROM flink:1.20.0
8+
WORKDIR /opt/flink/usrlib
9+
COPY --from=builder /build/target/*.jar /opt/flink/usrlib/

backend/src/analytics/db.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { User } from "@/auth/validation";
2+
import { PoolClient } from "pg";
3+
import { UserAnalytics } from "./types";
4+
import { WindowSchemaType } from "./validation";
5+
6+
const getUserAnalytics = async (
7+
client: PoolClient,
8+
user_id: User["user_id"],
9+
window: WindowSchemaType
10+
): Promise<UserAnalytics> => {
11+
const { count, unit } = window.interval;
12+
const interval = `${count} ${unit}`;
13+
const data = await client.query({
14+
text: `select lang_durations, machine_durations, editor_durations, project_durations, activity_durations from user_analytics_aggregate_period($1, $2, $3::interval)`,
15+
values: [user_id, window.start, interval],
16+
});
17+
console.log(data.rows[0]);
18+
return data.rows[0];
19+
};
20+
const db = { getUserAnalytics };
21+
22+
export default db;

backend/src/analytics/service.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { User } from "@/auth/validation";
2+
import pool from "@/pool";
3+
import { UserAnalytics } from "./types";
4+
import { WindowSchemaType } from "./validation";
5+
import db from "./db";
6+
7+
const getUserAnalytics = async (
8+
user_id: User["user_id"],
9+
window: WindowSchemaType
10+
): Promise<UserAnalytics> => {
11+
const client = await pool.connect();
12+
13+
try {
14+
const data = await db.getUserAnalytics(client, user_id, window);
15+
return data;
16+
} finally {
17+
client.release();
18+
}
19+
};
20+
21+
export { getUserAnalytics };

backend/src/analytics/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Prettify from "@/utils/prettify";
2+
3+
type Duration<Key extends string = string> = {
4+
[K in Key]: number;
5+
};
6+
7+
export type UserAnalytics = Prettify<{
8+
lang_durations: Duration;
9+
project_durations: Duration;
10+
editor_durations: Duration;
11+
activity_durations: Duration;
12+
machine_durations: Duration;
13+
}>;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { z } from "zod";
2+
3+
export const WindowSchema = z.object({
4+
start: z.iso.date().transform((x) => new Date(x)),
5+
interval: z.object({
6+
unit: z.enum(["day", "week", "month", "year"]),
7+
count: z.number().int().positive(),
8+
}),
9+
});
10+
11+
export type WindowSchemaType = z.infer<typeof WindowSchema>;
12+
export type WindowSchemaInputType = z.input<typeof WindowSchema>;

backend/src/api/middleware.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { AppResponse, errToResponse } from "@/utils/responses";
44
import { getContext } from "@getcronit/pylon";
55
import { ApiKey, ApiKeySchema } from "./validation";
66
import { getUserByApi } from "./db";
7-
import { AppError } from "@/utils/error";
7+
import { AppError, InternalServerError } from "@/utils/error";
88
import { InvalidApi } from "./errors";
99
import pool from "@/pool";
1010

@@ -24,6 +24,11 @@ export function withApi<TArgs extends unknown[], TReturn>(
2424
client.release();
2525
if (user instanceof AppError) return errToResponse(user); // NOTE: ! this doens't feel right?
2626

27-
return await fn(user, parsed.data, ...args);
27+
try {
28+
return await fn(user, parsed.data, ...args);
29+
} catch (err) {
30+
console.error(err);
31+
return errToResponse(new InternalServerError());
32+
}
2833
};
2934
}

backend/src/api/service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const setApiMetadata = async (
4646
const client = await pool.connect();
4747
try {
4848
await client.query("begin transaction");
49+
console.log(`setting ${api_key}`);
4950
const res = await db.setAPIMetadata(
5051
client,
5152
api_key,

backend/src/auth/middleware.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,33 @@ import { AppResponse, errToResponse } from "@/utils/responses";
33
import { User, UserSchema } from "./validation";
44
import { getContext } from "@getcronit/pylon";
55
import { InvalidToken, UnauthorizedUser } from "./errors";
6+
import { InternalServerError } from "@/utils/error";
67

7-
type Tail<T extends unknown[]> = T extends [unknown, ...infer R] ? R : never;
8-
9-
export function authorized<T>(
10-
fn: (user: User, ...args) => Promise<AppResponse<T>>
11-
) {
12-
return async (
13-
...args: Tail<Parameters<typeof fn>>
14-
): Promise<ReturnType<typeof fn>> => {
8+
export function authorized<TArgs extends unknown[], TReturn>(
9+
fn: (user: User, ...args: TArgs) => Promise<AppResponse<TReturn>>
10+
): (...args: TArgs) => Promise<AppResponse<TReturn>> {
11+
return async (...args: TArgs): Promise<AppResponse<TReturn>> => {
1512
const ctx = getContext();
1613
const authorization = ctx.req.header("Authorization");
1714
if (!authorization) return errToResponse(new UnauthorizedUser());
1815

19-
const sliced = authorization.split(" ");
20-
if (sliced.length !== 2 || sliced[0] !== "Bearer")
16+
const [scheme, token] = authorization.split(" ");
17+
if (scheme !== "Bearer" || !token)
2118
return errToResponse(new UnauthorizedUser());
2219

23-
const token = sliced[1];
20+
let user: User;
2421
try {
2522
const payload = await verifyToken(token);
26-
const data = UserSchema.parse(payload);
27-
// Spread user into original fn
28-
return await fn(data, ...args);
23+
user = UserSchema.parse(payload);
24+
} catch (_err) {
25+
return errToResponse(new InvalidToken());
26+
}
27+
28+
try {
29+
return await fn(user, ...args);
2930
} catch (err) {
3031
console.error(err);
31-
return errToResponse(new InvalidToken());
32+
return errToResponse(new InternalServerError());
3233
}
3334
};
3435
}

backend/src/index.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,35 @@ import {
1818
ProjectSessionSchema,
1919
} from "./heartbeat/validation";
2020
import * as heartbeat from "./heartbeat/service";
21+
import * as analytics from "./analytics/service";
22+
import {
23+
WindowSchema,
24+
WindowSchemaInputType,
25+
WindowSchemaType,
26+
} from "./analytics/validation";
27+
import { UserAnalytics } from "./analytics/types";
2128

2229
export const graphql = {
2330
Query: {
24-
hello: authorized(async (user) => SuccessResponse({ ...user })),
31+
UserAnaltyics: authorized(
32+
async (
33+
user,
34+
window: WindowSchemaInputType
35+
): Promise<AppResponse<UserAnalytics>> => {
36+
// validate first
37+
const parsed_window = WindowSchema.safeParse(window);
38+
if (parsed_window.error) {
39+
return ErrorResponse(
40+
parsed_window.error.issues.map((x) => x.message)
41+
);
42+
}
43+
const data = await analytics.getUserAnalytics(
44+
user.user_id,
45+
parsed_window.data
46+
);
47+
return SuccessResponse<UserAnalytics>(data);
48+
}
49+
),
2550
},
2651
Mutation: {
2752
async login(

backend/src/utils/error.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ export class AppError extends Error {
2323
Error.captureStackTrace?.(this, this.constructor);
2424
}
2525
}
26+
27+
export class InternalServerError extends AppError {
28+
constructor() {
29+
super("Internal Server Error");
30+
}
31+
}

0 commit comments

Comments
 (0)