Skip to content

Commit 60984ae

Browse files
committed
feat(countries): implement country profile endpoint and related services
- Add endpoint to retrieve country profile by ID, including detailed analytics and statistics - Implement countries 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 country profile features
1 parent a072601 commit 60984ae

File tree

8 files changed

+554
-1
lines changed

8 files changed

+554
-1
lines changed

docs/USAGE.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,58 @@ curl -H "User-Agent: MyApp/1.0 (contact@example.com)" \
368368

369369
### Country Profile Endpoint
370370

371+
Get detailed country profile with analytics and statistics.
372+
373+
```bash
374+
curl -H "User-Agent: MyApp/1.0 (contact@example.com)" \
375+
http://localhost:3000/api/v1/countries/42
376+
```
377+
378+
**Response**:
379+
```json
380+
{
381+
"data": {
382+
"dimension_country_id": 45,
383+
"country_id": 42,
384+
"country_name": "Colombia",
385+
"country_name_en": "Colombia",
386+
"country_name_es": "Colombia",
387+
"iso_alpha2": "CO",
388+
"history_whole_open": 1000,
389+
"history_whole_closed": 800,
390+
"avg_days_to_resolution": 7.2,
391+
"resolution_rate": 80.0,
392+
"notes_health_score": 75.5,
393+
"new_vs_resolved_ratio": 1.2,
394+
"notes_backlog_size": 50,
395+
"notes_created_last_30_days": 100,
396+
"notes_resolved_last_30_days": 80,
397+
"users_open_notes": [
398+
{
399+
"rank": 1,
400+
"user_id": 12345,
401+
"username": "top_user",
402+
"quantity": 50
403+
}
404+
],
405+
"applications_used": [],
406+
"hashtags": [],
407+
"activity_by_year": {},
408+
"working_hours_of_week_opening": []
409+
}
410+
}
411+
```
412+
413+
**Error Responses**:
414+
- `400 Bad Request`: Invalid country ID format
415+
- `404 Not Found`: Country does not exist
416+
- `500 Internal Server Error`: Server error
417+
418+
**Example**:
371419
```bash
420+
# Get country profile
372421
curl -H "User-Agent: MyApp/1.0 (contact@example.com)" \
373-
http://localhost:3000/api/v1/countries/CO
422+
http://localhost:3000/api/v1/countries/42
374423
```
375424

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

src/routes/countries.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Countries routes
3+
*/
4+
5+
import { Router, Request, Response, NextFunction } from 'express';
6+
import * as countriesController from '../controllers/countriesController';
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/countries/:country_id
21+
* @desc Get country profile by ID
22+
* @access Public
23+
*/
24+
router.get('/:country_id', asyncHandler(countriesController.getCountryProfile));
25+
26+
export default router;

src/routes/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getAppConfig } from '../config/app';
77
import healthRouter from './health';
88
import notesRouter from './notes';
99
import usersRouter from './users';
10+
import countriesRouter from './countries';
1011

1112
const router = Router();
1213
const { apiVersion } = getAppConfig();
@@ -38,4 +39,9 @@ router.use(`/api/${apiVersion}/notes`, notesRouter);
3839
*/
3940
router.use(`/api/${apiVersion}/users`, usersRouter);
4041

42+
/**
43+
* Countries routes
44+
*/
45+
router.use(`/api/${apiVersion}/countries`, countriesRouter);
46+
4147
export default router;

src/services/countryService.ts

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

src/types/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,29 @@ export interface UserProfile {
9494
working_hours_of_week_opening?: unknown; // JSON array (number[])
9595
activity_by_year?: unknown; // JSON object
9696
}
97+
98+
/**
99+
* Country profile from datamartCountries
100+
*/
101+
export interface CountryProfile {
102+
dimension_country_id: number;
103+
country_id: number;
104+
country_name: string | null;
105+
country_name_en: string | null;
106+
country_name_es: string | null;
107+
iso_alpha2: string | null;
108+
history_whole_open: number;
109+
history_whole_closed: number;
110+
avg_days_to_resolution: number | null;
111+
resolution_rate: number | null;
112+
notes_health_score: number | null;
113+
new_vs_resolved_ratio: number | null;
114+
notes_backlog_size: number | null;
115+
notes_created_last_30_days: number | null;
116+
notes_resolved_last_30_days: number | null;
117+
users_open_notes?: unknown; // JSON array
118+
applications_used?: unknown; // JSON array
119+
hashtags?: unknown; // JSON array (string[])
120+
activity_by_year?: unknown; // JSON object
121+
working_hours_of_week_opening?: unknown; // JSON array (number[])
122+
}

0 commit comments

Comments
 (0)