Skip to content

Commit a072601

Browse files
committed
feat(users): implement user profile endpoint and related services
- Add endpoint to retrieve user profile by ID, including detailed analytics and statistics - Implement user profile controller and service logic to handle data retrieval from the database - Enhance documentation in USAGE.md with usage examples and error response structures for the new endpoint - Introduce integration and unit tests to ensure functionality and reliability of user profile features
1 parent 64600b3 commit a072601

File tree

8 files changed

+536
-0
lines changed

8 files changed

+536
-0
lines changed

docs/USAGE.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,65 @@ curl -H "User-Agent: MyApp/1.0 (contact@example.com)" \
303303

304304
### User Profile Endpoint
305305

306+
Get detailed user profile with analytics and statistics.
307+
308+
```bash
309+
curl -H "User-Agent: MyApp/1.0 (contact@example.com)" \
310+
http://localhost:3000/api/v1/users/12345
311+
```
312+
313+
**Response**:
314+
```json
315+
{
316+
"data": {
317+
"dimension_user_id": 123,
318+
"user_id": 12345,
319+
"username": "example_user",
320+
"history_whole_open": 100,
321+
"history_whole_closed": 50,
322+
"history_whole_commented": 75,
323+
"avg_days_to_resolution": 5.5,
324+
"resolution_rate": 50.0,
325+
"user_response_time": 2.3,
326+
"days_since_last_action": 5,
327+
"applications_used": [
328+
{
329+
"application_id": 1,
330+
"application_name": "JOSM",
331+
"count": 80
332+
}
333+
],
334+
"collaboration_patterns": {
335+
"mentions_given": 10,
336+
"mentions_received": 5,
337+
"replies_count": 20,
338+
"collaboration_score": 0.75
339+
},
340+
"countries_open_notes": [
341+
{
342+
"rank": 1,
343+
"country": "Colombia",
344+
"quantity": 50
345+
}
346+
],
347+
"hashtags": ["#MapColombia", "#MissingMaps"],
348+
"date_starting_creating_notes": "2020-01-15",
349+
"date_starting_solving_notes": "2020-02-01",
350+
"last_year_activity": null,
351+
"working_hours_of_week_opening": [],
352+
"activity_by_year": {}
353+
}
354+
}
355+
```
356+
357+
**Error Responses**:
358+
- `400 Bad Request`: Invalid user ID format
359+
- `404 Not Found`: User does not exist
360+
- `500 Internal Server Error`: Server error
361+
362+
**Example**:
306363
```bash
364+
# Get user profile
307365
curl -H "User-Agent: MyApp/1.0 (contact@example.com)" \
308366
http://localhost:3000/api/v1/users/12345
309367
```

src/controllers/usersController.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Users controller
3+
* Handles HTTP requests for users endpoints
4+
*/
5+
6+
import { Request, Response, NextFunction } from 'express';
7+
import * as userService from '../services/userService';
8+
import { logger } from '../utils/logger';
9+
import { ApiError } from '../middleware/errorHandler';
10+
11+
/**
12+
* Get user profile by ID
13+
* GET /api/v1/users/:user_id
14+
*/
15+
export async function getUserProfile(
16+
req: Request,
17+
res: Response,
18+
next: NextFunction
19+
): Promise<void> {
20+
try {
21+
const userId = parseInt(req.params.user_id, 10);
22+
23+
if (isNaN(userId) || userId <= 0) {
24+
throw new ApiError(400, 'Invalid user ID');
25+
}
26+
27+
logger.debug('Getting user profile by ID', { userId });
28+
29+
const userProfile = await userService.getUserProfile(userId);
30+
31+
res.json({
32+
data: userProfile,
33+
});
34+
} catch (error) {
35+
next(error);
36+
}
37+
}

src/routes/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Router } from 'express';
66
import { getAppConfig } from '../config/app';
77
import healthRouter from './health';
88
import notesRouter from './notes';
9+
import usersRouter from './users';
910

1011
const router = Router();
1112
const { apiVersion } = getAppConfig();
@@ -32,4 +33,9 @@ router.use('/health', healthRouter);
3233
*/
3334
router.use(`/api/${apiVersion}/notes`, notesRouter);
3435

36+
/**
37+
* Users routes
38+
*/
39+
router.use(`/api/${apiVersion}/users`, usersRouter);
40+
3541
export default router;

src/routes/users.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Users routes
3+
*/
4+
5+
import { Router, Request, Response, NextFunction } from 'express';
6+
import * as usersController from '../controllers/usersController';
7+
8+
const router = Router();
9+
10+
/**
11+
* Async wrapper for route handlers
12+
*/
13+
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
14+
return (req: Request, res: Response, next: NextFunction): void => {
15+
void Promise.resolve(fn(req, res, next)).catch(next);
16+
};
17+
}
18+
19+
/**
20+
* @route GET /api/v1/users/:user_id
21+
* @desc Get user profile by ID
22+
* @access Public
23+
*/
24+
router.get('/:user_id', asyncHandler(usersController.getUserProfile));
25+
26+
export default router;

src/services/userService.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* User service
3+
* Handles business logic for user operations
4+
*/
5+
6+
import { getDatabasePool } from '../config/database';
7+
import { logger } from '../utils/logger';
8+
import { ApiError } from '../middleware/errorHandler';
9+
import { UserProfile } from '../types';
10+
11+
/**
12+
* Database row type for user query result
13+
*/
14+
interface UserRow {
15+
dimension_user_id: number;
16+
user_id: number;
17+
username: string | null;
18+
history_whole_open: number | string;
19+
history_whole_closed: number | string;
20+
history_whole_commented: number | string;
21+
avg_days_to_resolution: number | string | null;
22+
resolution_rate: number | string | null;
23+
user_response_time: number | string | null;
24+
days_since_last_action: number | string | null;
25+
applications_used?: unknown;
26+
collaboration_patterns?: unknown;
27+
countries_open_notes?: unknown;
28+
hashtags?: unknown;
29+
date_starting_creating_notes?: Date | string | null;
30+
date_starting_solving_notes?: Date | string | null;
31+
last_year_activity?: string | null;
32+
working_hours_of_week_opening?: unknown;
33+
activity_by_year?: unknown;
34+
}
35+
36+
/**
37+
* Get user profile by user ID
38+
* @param userId - The user ID
39+
* @returns The user profile object
40+
* @throws ApiError with 404 if user not found
41+
* @throws ApiError with 500 if database error occurs
42+
*/
43+
export async function getUserProfile(userId: number): Promise<UserProfile> {
44+
const pool = getDatabasePool();
45+
46+
try {
47+
const query = `
48+
SELECT
49+
dimension_user_id,
50+
user_id,
51+
username,
52+
history_whole_open,
53+
history_whole_closed,
54+
history_whole_commented,
55+
avg_days_to_resolution,
56+
resolution_rate,
57+
user_response_time,
58+
days_since_last_action,
59+
applications_used,
60+
collaboration_patterns,
61+
countries_open_notes,
62+
hashtags,
63+
date_starting_creating_notes,
64+
date_starting_solving_notes,
65+
last_year_activity,
66+
working_hours_of_week_opening,
67+
activity_by_year
68+
FROM dwh.datamartUsers
69+
WHERE user_id = $1
70+
`;
71+
72+
logger.debug('Executing query to get user profile', { userId });
73+
74+
const result = await pool.query<UserRow>(query, [userId]);
75+
76+
if (result.rows.length === 0) {
77+
logger.warn('User not found', { userId });
78+
throw new ApiError(404, 'User not found');
79+
}
80+
81+
const row = result.rows[0];
82+
83+
// Convert numeric fields from string to number if needed
84+
const userProfile: UserProfile = {
85+
dimension_user_id: row.dimension_user_id,
86+
user_id: row.user_id,
87+
username: row.username,
88+
history_whole_open:
89+
typeof row.history_whole_open === 'string'
90+
? parseInt(row.history_whole_open, 10)
91+
: row.history_whole_open,
92+
history_whole_closed:
93+
typeof row.history_whole_closed === 'string'
94+
? parseInt(row.history_whole_closed, 10)
95+
: row.history_whole_closed,
96+
history_whole_commented:
97+
typeof row.history_whole_commented === 'string'
98+
? parseInt(row.history_whole_commented, 10)
99+
: row.history_whole_commented,
100+
avg_days_to_resolution:
101+
row.avg_days_to_resolution === null
102+
? null
103+
: typeof row.avg_days_to_resolution === 'string'
104+
? parseFloat(row.avg_days_to_resolution)
105+
: row.avg_days_to_resolution,
106+
resolution_rate:
107+
row.resolution_rate === null
108+
? null
109+
: typeof row.resolution_rate === 'string'
110+
? parseFloat(row.resolution_rate)
111+
: row.resolution_rate,
112+
user_response_time:
113+
row.user_response_time === null
114+
? null
115+
: typeof row.user_response_time === 'string'
116+
? parseFloat(row.user_response_time)
117+
: row.user_response_time,
118+
days_since_last_action:
119+
row.days_since_last_action === null
120+
? null
121+
: typeof row.days_since_last_action === 'string'
122+
? parseInt(row.days_since_last_action, 10)
123+
: row.days_since_last_action,
124+
applications_used: row.applications_used,
125+
collaboration_patterns: row.collaboration_patterns,
126+
countries_open_notes: row.countries_open_notes,
127+
hashtags: row.hashtags,
128+
date_starting_creating_notes: row.date_starting_creating_notes,
129+
date_starting_solving_notes: row.date_starting_solving_notes,
130+
last_year_activity: row.last_year_activity,
131+
working_hours_of_week_opening: row.working_hours_of_week_opening,
132+
activity_by_year: row.activity_by_year,
133+
};
134+
135+
logger.debug('User profile retrieved successfully', {
136+
userId,
137+
username: userProfile.username,
138+
});
139+
140+
return userProfile;
141+
} catch (error) {
142+
if (error instanceof ApiError) {
143+
throw error;
144+
}
145+
146+
logger.error('Error getting user profile', {
147+
userId,
148+
error: error instanceof Error ? error.message : String(error),
149+
});
150+
151+
throw new ApiError(500, 'Internal server error');
152+
}
153+
}

src/types/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,28 @@ export interface SearchResult<T> {
6969
pagination: Pagination;
7070
filters?: Partial<SearchFilters>;
7171
}
72+
73+
/**
74+
* User profile from datamartUsers
75+
*/
76+
export interface UserProfile {
77+
dimension_user_id: number;
78+
user_id: number;
79+
username: string | null;
80+
history_whole_open: number;
81+
history_whole_closed: number;
82+
history_whole_commented: number;
83+
avg_days_to_resolution: number | null;
84+
resolution_rate: number | null;
85+
user_response_time: number | null;
86+
days_since_last_action: number | null;
87+
applications_used?: unknown; // JSON array
88+
collaboration_patterns?: unknown; // JSON object
89+
countries_open_notes?: unknown; // JSON array
90+
hashtags?: unknown; // JSON array (string[])
91+
date_starting_creating_notes?: Date | string | null;
92+
date_starting_solving_notes?: Date | string | null;
93+
last_year_activity?: string | null;
94+
working_hours_of_week_opening?: unknown; // JSON array (number[])
95+
activity_by_year?: unknown; // JSON object
96+
}

0 commit comments

Comments
 (0)