Compare commits

..

No commits in common. "9afc9a43922a37d7958333845f1523445ca0ffc1" and "c711df68b9ef0fc1c57768aa9e3d34d3089af4da" have entirely different histories.

11 changed files with 322 additions and 1588 deletions

View File

@ -1,12 +1,6 @@
-- =====================
-- Analytics (date-filtered)
-- Analytics
-- =====================
-- Shared optional params (nullable = all-time):
-- range_start, range_end (exclusive upper bound)
-- Required chart params:
-- series_start, series_end (inclusive dates)
-- Relative window anchor:
-- ref_date (inclusive date used for new_today/week/month)
-- =====================
-- User Analytics
@ -15,67 +9,49 @@
-- name: AnalyticsUsersSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE u.created_at::date = sqlc.arg('ref_date')::date)::bigint AS new_today,
COUNT(*) FILTER (
WHERE u.created_at::date >= sqlc.arg('ref_date')::date - INTERVAL '6 days'
AND u.created_at::date <= sqlc.arg('ref_date')::date
)::bigint AS new_this_week,
COUNT(*) FILTER (
WHERE u.created_at::date >= sqlc.arg('ref_date')::date - INTERVAL '29 days'
AND u.created_at::date <= sqlc.arg('ref_date')::date
)::bigint AS new_this_month
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz);
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE)::bigint AS new_today,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '7 days')::bigint AS new_this_week,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '30 days')::bigint AS new_this_month
FROM users;
-- name: AnalyticsUsersByRole :many
SELECT
COALESCE(u.role, 'unknown') AS role,
COALESCE(role, 'unknown') AS role,
COUNT(*)::bigint AS count
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY u.role
FROM users
GROUP BY role
ORDER BY count DESC;
-- name: AnalyticsUsersByStatus :many
SELECT
COALESCE(u.status, 'unknown') AS status,
COALESCE(status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY u.status
FROM users
GROUP BY status
ORDER BY count DESC;
-- name: AnalyticsUsersByAgeGroup :many
SELECT
COALESCE(u.age_group, 'unknown') AS age_group,
COALESCE(age_group, 'unknown') AS age_group,
COUNT(*)::bigint AS count
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY u.age_group
FROM users
GROUP BY age_group
ORDER BY count DESC;
-- name: AnalyticsUsersByKnowledgeLevel :many
SELECT
COALESCE(u.knowledge_level, 'unknown') AS knowledge_level,
COALESCE(knowledge_level, 'unknown') AS knowledge_level,
COUNT(*)::bigint AS count
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY u.knowledge_level
FROM users
GROUP BY knowledge_level
ORDER BY count DESC;
-- name: AnalyticsUsersByRegion :many
SELECT
COALESCE(u.region, 'unknown') AS region,
COALESCE(region, 'unknown') AS region,
COUNT(*)::bigint AS count
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY u.region
FROM users
GROUP BY region
ORDER BY count DESC;
-- name: AnalyticsUserRegistrationsLast30Days :many
@ -83,13 +59,11 @@ SELECT
d.date,
COUNT(u.id)::bigint AS count
FROM generate_series(
sqlc.arg('series_start')::date,
sqlc.arg('series_end')::date,
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN users u ON u.created_at::date = d.date
AND (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY d.date
ORDER BY d.date;
@ -100,28 +74,18 @@ ORDER BY d.date;
-- name: AnalyticsSubscriptionsSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE us.status = 'ACTIVE')::bigint AS active,
COUNT(*) FILTER (WHERE us.created_at::date = sqlc.arg('ref_date')::date)::bigint AS new_today,
COUNT(*) FILTER (
WHERE us.created_at::date >= sqlc.arg('ref_date')::date - INTERVAL '6 days'
AND us.created_at::date <= sqlc.arg('ref_date')::date
)::bigint AS new_this_week,
COUNT(*) FILTER (
WHERE us.created_at::date >= sqlc.arg('ref_date')::date - INTERVAL '29 days'
AND us.created_at::date <= sqlc.arg('ref_date')::date
)::bigint AS new_this_month
FROM user_subscriptions us
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR us.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR us.created_at < sqlc.narg('range_end')::timestamptz);
COUNT(*) FILTER (WHERE status = 'ACTIVE')::bigint AS active,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE)::bigint AS new_today,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '7 days')::bigint AS new_this_week,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '30 days')::bigint AS new_this_month
FROM user_subscriptions;
-- name: AnalyticsSubscriptionsByStatus :many
SELECT
COALESCE(us.status, 'unknown') AS status,
COALESCE(status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM user_subscriptions us
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR us.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR us.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY us.status
FROM user_subscriptions
GROUP BY status
ORDER BY count DESC;
-- name: AnalyticsRevenueByPlan :many
@ -133,8 +97,6 @@ SELECT
FROM payments p
JOIN subscription_plans sp ON sp.id = p.plan_id
WHERE p.status = 'SUCCESS'
AND (sqlc.narg('range_start')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < sqlc.narg('range_end')::timestamptz)
GROUP BY sp.name, sp.currency
ORDER BY total_revenue DESC;
@ -143,13 +105,11 @@ SELECT
d.date,
COUNT(us.id)::bigint AS count
FROM generate_series(
sqlc.arg('series_start')::date,
sqlc.arg('series_end')::date,
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN user_subscriptions us ON us.created_at::date = d.date
AND (sqlc.narg('range_start')::timestamptz IS NULL OR us.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR us.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY d.date
ORDER BY d.date;
@ -159,35 +119,29 @@ ORDER BY d.date;
-- name: AnalyticsPaymentsSummary :one
SELECT
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'SUCCESS'), 0)::float8 AS total_revenue,
COALESCE(AVG(p.amount) FILTER (WHERE p.status = 'SUCCESS'), 0)::float8 AS avg_value,
COALESCE(SUM(amount) FILTER (WHERE status = 'SUCCESS'), 0)::float8 AS total_revenue,
COALESCE(AVG(amount) FILTER (WHERE status = 'SUCCESS'), 0)::float8 AS avg_value,
COUNT(*)::bigint AS total_payments,
COUNT(*) FILTER (WHERE p.status = 'SUCCESS')::bigint AS successful_payments
FROM payments p
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < sqlc.narg('range_end')::timestamptz);
COUNT(*) FILTER (WHERE status = 'SUCCESS')::bigint AS successful_payments
FROM payments;
-- name: AnalyticsPaymentsByStatus :many
SELECT
COALESCE(p.status, 'unknown') AS status,
COALESCE(status, 'unknown') AS status,
COUNT(*)::bigint AS count,
COALESCE(SUM(p.amount), 0)::float8 AS total_amount
FROM payments p
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < sqlc.narg('range_end')::timestamptz)
GROUP BY p.status
COALESCE(SUM(amount), 0)::float8 AS total_amount
FROM payments
GROUP BY status
ORDER BY count DESC;
-- name: AnalyticsPaymentsByMethod :many
SELECT
COALESCE(p.payment_method, 'unknown') AS payment_method,
COALESCE(payment_method, 'unknown') AS payment_method,
COUNT(*)::bigint AS count,
COALESCE(SUM(p.amount), 0)::float8 AS total_amount
FROM payments p
WHERE p.status = 'SUCCESS'
AND (sqlc.narg('range_start')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < sqlc.narg('range_end')::timestamptz)
GROUP BY p.payment_method
COALESCE(SUM(amount), 0)::float8 AS total_amount
FROM payments
WHERE status = 'SUCCESS'
GROUP BY payment_method
ORDER BY count DESC;
-- name: AnalyticsRevenueLast30Days :many
@ -195,14 +149,11 @@ SELECT
d.date,
COALESCE(SUM(p.amount), 0)::float8 AS total_revenue
FROM generate_series(
sqlc.arg('series_start')::date,
sqlc.arg('series_end')::date,
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN payments p ON COALESCE(p.paid_at, p.created_at)::date = d.date
AND p.status = 'SUCCESS'
AND (sqlc.narg('range_start')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < sqlc.narg('range_end')::timestamptz)
LEFT JOIN payments p ON p.paid_at::date = d.date AND p.status = 'SUCCESS'
GROUP BY d.date
ORDER BY d.date;
@ -223,37 +174,23 @@ SELECT
-- name: AnalyticsQuestionsCounts :one
SELECT
(
SELECT COUNT(*)::bigint
FROM questions q
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR q.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR q.created_at < sqlc.narg('range_end')::timestamptz)
) AS total_questions,
(
SELECT COUNT(*)::bigint
FROM question_sets qs
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR qs.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR qs.created_at < sqlc.narg('range_end')::timestamptz)
) AS total_question_sets;
(SELECT COUNT(*)::bigint FROM questions) AS total_questions,
(SELECT COUNT(*)::bigint FROM question_sets) AS total_question_sets;
-- name: AnalyticsQuestionsByType :many
SELECT
COALESCE(q.question_type, 'unknown') AS question_type,
COALESCE(question_type, 'unknown') AS question_type,
COUNT(*)::bigint AS count
FROM questions q
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR q.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR q.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY q.question_type
FROM questions
GROUP BY question_type
ORDER BY count DESC;
-- name: AnalyticsQuestionSetsByType :many
SELECT
COALESCE(qs.set_type, 'unknown') AS set_type,
COALESCE(set_type, 'unknown') AS set_type,
COUNT(*)::bigint AS count
FROM question_sets qs
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR qs.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR qs.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY qs.set_type
FROM question_sets
GROUP BY set_type
ORDER BY count DESC;
-- =====================
@ -263,30 +200,24 @@ ORDER BY count DESC;
-- name: AnalyticsNotificationsSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE n.is_read = TRUE)::bigint AS read,
COUNT(*) FILTER (WHERE n.is_read = FALSE)::bigint AS unread
FROM notifications n
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR n.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR n.created_at < sqlc.narg('range_end')::timestamptz);
COUNT(*) FILTER (WHERE is_read = TRUE)::bigint AS read,
COUNT(*) FILTER (WHERE is_read = FALSE)::bigint AS unread
FROM notifications;
-- name: AnalyticsNotificationsByChannel :many
SELECT
COALESCE(n.channel, 'unknown') AS channel,
COALESCE(channel, 'unknown') AS channel,
COUNT(*)::bigint AS count
FROM notifications n
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR n.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR n.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY n.channel
FROM notifications
GROUP BY channel
ORDER BY count DESC;
-- name: AnalyticsNotificationsByType :many
SELECT
COALESCE(n.type, 'unknown') AS type,
COALESCE(type, 'unknown') AS type,
COUNT(*)::bigint AS count
FROM notifications n
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR n.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR n.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY n.type
FROM notifications
GROUP BY type
ORDER BY count DESC;
-- =====================
@ -296,33 +227,27 @@ ORDER BY count DESC;
-- name: AnalyticsIssuesSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE ri.status = 'resolved')::bigint AS resolved,
COUNT(*) FILTER (WHERE status = 'resolved')::bigint AS resolved,
CASE
WHEN COUNT(*) > 0 THEN (COUNT(*) FILTER (WHERE ri.status = 'resolved')::float8 / COUNT(*)::float8)
WHEN COUNT(*) > 0 THEN (COUNT(*) FILTER (WHERE status = 'resolved')::float8 / COUNT(*)::float8)
ELSE 0::float8
END AS resolution_rate
FROM reported_issues ri
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR ri.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR ri.created_at < sqlc.narg('range_end')::timestamptz);
FROM reported_issues;
-- name: AnalyticsIssuesByStatus :many
SELECT
COALESCE(ri.status, 'unknown') AS status,
COALESCE(status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM reported_issues ri
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR ri.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR ri.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY ri.status
FROM reported_issues
GROUP BY status
ORDER BY count DESC;
-- name: AnalyticsIssuesByType :many
SELECT
COALESCE(ri.issue_type, 'unknown') AS issue_type,
COALESCE(issue_type, 'unknown') AS issue_type,
COUNT(*)::bigint AS count
FROM reported_issues ri
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR ri.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR ri.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY ri.issue_type
FROM reported_issues
GROUP BY issue_type
ORDER BY count DESC;
-- =====================
@ -332,26 +257,20 @@ ORDER BY count DESC;
-- name: AnalyticsTeamSummary :one
SELECT
COUNT(*)::bigint AS total_members
FROM team_members tm
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR tm.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR tm.created_at < sqlc.narg('range_end')::timestamptz);
FROM team_members;
-- name: AnalyticsTeamByRole :many
SELECT
COALESCE(tm.team_role, 'unknown') AS team_role,
COALESCE(team_role, 'unknown') AS team_role,
COUNT(*)::bigint AS count
FROM team_members tm
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR tm.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR tm.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY tm.team_role
FROM team_members
GROUP BY team_role
ORDER BY count DESC;
-- name: AnalyticsTeamByStatus :many
SELECT
COALESCE(tm.status, 'unknown') AS status,
COALESCE(status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM team_members tm
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR tm.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR tm.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY tm.status
FROM team_members
GROUP BY status
ORDER BY count DESC;

View File

@ -1,540 +0,0 @@
# Practice Creation API Guide (Lesson Scope)
This guide provides the full step-by-step API process to create a lesson practice when using:
- system-defined question types (`MCQ`, `TRUE_FALSE`, `SHORT_ANSWER`, `AUDIO`)
- dynamic question types (`DYNAMIC` with `question_type_definition_id` + `dynamic_payload`)
All endpoints below are relative to `/api/v1` and require bearer authentication.
---
## Standard Response Envelope
Most successful responses follow:
```json
{
"message": "Human-readable message",
"data": {},
"success": true,
"status_code": 200,
"metadata": null
}
```
Most errors follow:
```json
{
"message": "Error summary",
"error": "Detailed reason"
}
```
---
## Required Permissions
At minimum, your role should have:
- `questions.create`
- `question_sets.create`
- `question_set_items.add`
- `practices.create`
If you create/update dynamic definitions:
- `questions.update`
- `questions.delete` (if you also delete definitions)
---
## End-to-End Flow
1. (Optional) Upload media assets
2. Create question(s):
- system-defined path, or
- dynamic path (definition + question)
3. Create `PRACTICE` question set
4. Add question(s) to the set
5. Create lesson practice linked to that set
6. Verify under lesson
---
## Step 0 (Optional): Upload Media
Use this when question content references audio/image URLs.
### Endpoint
`POST /files/upload` (multipart form-data)
### Form fields
- `file`: binary
- `media_type`: `image` or `audio` or `video`
### Example success response (shape)
```json
{
"message": "Media uploaded successfully",
"data": {
"url": "https://your-host/static/uploads/audio/abc.mp3",
"object_key": "audio/abc.mp3"
},
"success": true,
"status_code": 201
}
```
### Common errors
- `400` invalid media type/content type
- `500` upload/storage failure
Capture and reuse:
- `data.url` (or equivalent resolved file URL)
---
## Step 1A: Create System-Defined Question(s)
### Endpoint
`POST /questions`
### Request example (MCQ)
```json
{
"question_text": "Choose the correct sentence.",
"question_type": "MCQ",
"difficulty_level": "EASY",
"points": 1,
"status": "PUBLISHED",
"options": [
{ "option_text": "He go to school.", "is_correct": false },
{ "option_text": "He goes to school.", "is_correct": true }
]
}
```
### Request example (SHORT_ANSWER)
```json
{
"question_text": "Write one sentence using the word 'improve'.",
"question_type": "SHORT_ANSWER",
"difficulty_level": "MEDIUM",
"points": 2,
"status": "PUBLISHED",
"short_answers": [
{ "acceptable_answer": "I want to improve my English.", "match_type": "CASE_INSENSITIVE" }
]
}
```
### Example success response (shape)
```json
{
"message": "Question created successfully",
"data": {
"id": 456,
"question_text": "Choose the correct sentence.",
"question_type": "MCQ",
"status": "PUBLISHED"
},
"success": true,
"status_code": 201
}
```
### Common errors
- `400` validation/body errors
- `500` create failure
Capture:
- `data.id` as `question_id`
---
## Step 1B: Dynamic Question Path
If you use dynamic questions, follow these sub-steps.
### 1B.1 Validate component-kind selection (optional but recommended)
#### Endpoint
`POST /questions/validate-question-type-definition`
#### Request
```json
{
"stimulus_component_kinds": ["QUESTION_TEXT", "AUDIO_PROMPT", "IMAGE"],
"response_component_kinds": ["AUDIO_RESPONSE", "TEXT_INPUT"]
}
```
#### Success response
```json
{
"message": "Question type definition is valid",
"data": { "valid": true }
}
```
#### Error response example
```json
{
"message": "Invalid question type definition",
"error": "response: unknown component kind \"AUDIO_PROMPT\""
}
```
---
### 1B.2 Create or reuse a dynamic type definition
#### Endpoint
`POST /questions/type-definitions`
#### Request
```json
{
"key": "dialogue_audio_avatar_v1",
"display_name": "Dialogue Audio + Avatar",
"description": "Question text + prompt audio + two avatar images, with audio/text answer",
"stimulus_component_kinds": ["QUESTION_TEXT", "AUDIO_PROMPT", "IMAGE"],
"response_component_kinds": ["AUDIO_RESPONSE", "TEXT_INPUT"],
"stimulus_schema": [
{ "id": "question_text", "kind": "QUESTION_TEXT", "required": true },
{ "id": "prompt_audio", "kind": "AUDIO_PROMPT", "required": true },
{ "id": "speaker_a_avatar", "kind": "IMAGE", "required": true },
{ "id": "speaker_b_avatar", "kind": "IMAGE", "required": true }
],
"response_schema": [
{ "id": "answer_audio", "kind": "AUDIO_RESPONSE", "required": true },
{ "id": "answer_text", "kind": "TEXT_INPUT", "required": true }
],
"status": "ACTIVE"
}
```
#### Success response example
```json
{
"message": "Question type definition created",
"data": {
"id": 123,
"key": "dialogue_audio_avatar_v1",
"status": "ACTIVE"
}
}
```
#### Common errors
- `400` invalid schema/kinds/mapping
- `500` unexpected persistence errors
Capture:
- `data.id` as `question_type_definition_id`
---
### 1B.3 Create dynamic question
#### Endpoint
`POST /questions`
#### Request
```json
{
"question_text": "Listen and respond as Speaker B.",
"question_type": "DYNAMIC",
"question_type_definition_id": 123,
"difficulty_level": "MEDIUM",
"points": 2,
"status": "PUBLISHED",
"dynamic_payload": {
"stimulus": [
{ "id": "question_text", "kind": "QUESTION_TEXT", "value": "Respond to the conversation." },
{ "id": "prompt_audio", "kind": "AUDIO_PROMPT", "value": "https://cdn.example.com/audio/prompt-1.mp3" },
{ "id": "speaker_a_avatar", "kind": "IMAGE", "value": "https://cdn.example.com/images/a.webp" },
{ "id": "speaker_b_avatar", "kind": "IMAGE", "value": "https://cdn.example.com/images/b.webp" }
],
"response": [
{ "id": "answer_audio", "kind": "AUDIO_RESPONSE", "value": { "instructions": "Record your answer" } },
{ "id": "answer_text", "kind": "TEXT_INPUT", "value": { "placeholder": "Type your answer" } }
]
}
}
```
#### Success response example
```json
{
"message": "Question created successfully",
"data": {
"id": 789,
"question_type": "DYNAMIC",
"question_type_definition_id": 123,
"status": "PUBLISHED"
},
"success": true,
"status_code": 201
}
```
#### Common errors
- `400` missing/invalid `dynamic_payload`
- `400` missing `question_type_definition_id`
- `500` persistence failure
Capture:
- `data.id` as `question_id`
---
## Step 2: Create PRACTICE Question Set
### Endpoint
`POST /question-sets`
### Request
```json
{
"title": "Lesson 12 - Practice Set",
"description": "Question set for lesson-level practice",
"set_type": "PRACTICE",
"owner_type": "LESSON",
"owner_id": 12,
"status": "PUBLISHED",
"shuffle_questions": false
}
```
### Success response example
```json
{
"message": "Question set created successfully",
"data": {
"id": 55,
"title": "Lesson 12 - Practice Set",
"set_type": "PRACTICE",
"owner_type": "LESSON",
"owner_id": 12,
"status": "PUBLISHED"
},
"success": true,
"status_code": 201
}
```
### Common errors
- `400` invalid input
- `500` create failure
Capture:
- `data.id` as `set_id`
---
## Step 3: Add Question(s) to Set
Run this once per `question_id`.
### Endpoint
`POST /question-sets/:setId/questions`
### Request
```json
{
"question_id": 456,
"display_order": 1
}
```
### Success response example
```json
{
"message": "Question added to set successfully",
"data": {
"id": 901,
"set_id": 55,
"question_id": 456,
"display_order": 1
},
"success": true,
"status_code": 201
}
```
### Common errors
- `400` invalid `setId` or body
- `500` link/create failure
---
## Step 4: Create Lesson Practice
This creates the practice record scoped to lesson.
### Endpoint
`POST /practices`
### Request
```json
{
"parent_kind": "LESSON",
"parent_id": 12,
"title": "Lesson 12 Conversation Drill",
"story_description": "A short two-speaker scenario.",
"story_image": "https://cdn.example.com/images/story.webp",
"question_set_id": 55,
"quick_tips": "Listen carefully before answering."
}
```
### Success response example
```json
{
"message": "Practice created successfully",
"data": {
"id": 37,
"parent_kind": "LESSON",
"parent_id": 12,
"title": "Lesson 12 Conversation Drill",
"question_set_id": 55
},
"success": true,
"status_code": 201
}
```
### Common errors
- `400` validation failed / invalid parent kind
- `404` lesson not found
- `404` question set not found
- `500` create failure
Capture:
- `data.id` as `practice_id`
---
## Step 5: Verify Practice Under Lesson
### Endpoint
`GET /lessons/:id/practices`
Example:
`GET /lessons/12/practices`
### Success response example
```json
{
"message": "Practices retrieved successfully",
"data": {
"practices": [
{
"id": 37,
"parent_kind": "LESSON",
"parent_id": 12,
"title": "Lesson 12 Conversation Drill",
"question_set_id": 55
}
],
"total_count": 1,
"limit": 20,
"offset": 0
},
"success": true,
"status_code": 200
}
```
---
## Optional Learner Completion Step
### Endpoint
`POST /progress/practices/:id/complete`
Use `practice_id` as `:id` for current behavior.
### Success response example
```json
{
"message": "Practice completed",
"success": true,
"status_code": 200
}
```
### Common errors
- `403` sequence gating violation
- `404` practice not found
- `500` completion/persistence failure
---
## Quick Checklist (IDs to Carry Forward)
- From question create: `question_id`
- From dynamic definition create (if used): `question_type_definition_id`
- From question set create: `set_id`
- From practice create: `practice_id`
---
## Notes and Pitfalls
- For dynamic questions, `question_type` must be `DYNAMIC`.
- For dynamic questions, both `question_type_definition_id` and `dynamic_payload` are required.
- `AUDIO_PROMPT` is stimulus-side; response-side audio uses `AUDIO_RESPONSE`.
- `question_set_id` in `POST /practices` must reference an existing set.
- For lesson practice always use:
- `parent_kind = "LESSON"`
- `parent_id = <lesson_id>`
- Publish questions and question set (`status = "PUBLISHED"`) if learners must complete immediately.

View File

@ -7,8 +7,6 @@ package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one
@ -44,27 +42,20 @@ func (q *Queries) AnalyticsCourseCounts(ctx context.Context) (AnalyticsCourseCou
const AnalyticsIssuesByStatus = `-- name: AnalyticsIssuesByStatus :many
SELECT
COALESCE(ri.status, 'unknown') AS status,
COALESCE(status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM reported_issues ri
WHERE ($1::timestamptz IS NULL OR ri.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR ri.created_at < $2::timestamptz)
GROUP BY ri.status
FROM reported_issues
GROUP BY status
ORDER BY count DESC
`
type AnalyticsIssuesByStatusParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsIssuesByStatusRow struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsIssuesByStatus(ctx context.Context, arg AnalyticsIssuesByStatusParams) ([]AnalyticsIssuesByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsIssuesByStatus, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsIssuesByStatus(ctx context.Context) ([]AnalyticsIssuesByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsIssuesByStatus)
if err != nil {
return nil, err
}
@ -85,27 +76,20 @@ func (q *Queries) AnalyticsIssuesByStatus(ctx context.Context, arg AnalyticsIssu
const AnalyticsIssuesByType = `-- name: AnalyticsIssuesByType :many
SELECT
COALESCE(ri.issue_type, 'unknown') AS issue_type,
COALESCE(issue_type, 'unknown') AS issue_type,
COUNT(*)::bigint AS count
FROM reported_issues ri
WHERE ($1::timestamptz IS NULL OR ri.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR ri.created_at < $2::timestamptz)
GROUP BY ri.issue_type
FROM reported_issues
GROUP BY issue_type
ORDER BY count DESC
`
type AnalyticsIssuesByTypeParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsIssuesByTypeRow struct {
IssueType string `json:"issue_type"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsIssuesByType(ctx context.Context, arg AnalyticsIssuesByTypeParams) ([]AnalyticsIssuesByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsIssuesByType, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsIssuesByType(ctx context.Context) ([]AnalyticsIssuesByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsIssuesByType)
if err != nil {
return nil, err
}
@ -128,21 +112,14 @@ const AnalyticsIssuesSummary = `-- name: AnalyticsIssuesSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE ri.status = 'resolved')::bigint AS resolved,
COUNT(*) FILTER (WHERE status = 'resolved')::bigint AS resolved,
CASE
WHEN COUNT(*) > 0 THEN (COUNT(*) FILTER (WHERE ri.status = 'resolved')::float8 / COUNT(*)::float8)
WHEN COUNT(*) > 0 THEN (COUNT(*) FILTER (WHERE status = 'resolved')::float8 / COUNT(*)::float8)
ELSE 0::float8
END AS resolution_rate
FROM reported_issues ri
WHERE ($1::timestamptz IS NULL OR ri.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR ri.created_at < $2::timestamptz)
FROM reported_issues
`
type AnalyticsIssuesSummaryParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsIssuesSummaryRow struct {
Total int64 `json:"total"`
Resolved int64 `json:"resolved"`
@ -152,8 +129,8 @@ type AnalyticsIssuesSummaryRow struct {
// =====================
// Issue Analytics
// =====================
func (q *Queries) AnalyticsIssuesSummary(ctx context.Context, arg AnalyticsIssuesSummaryParams) (AnalyticsIssuesSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsIssuesSummary, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsIssuesSummary(ctx context.Context) (AnalyticsIssuesSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsIssuesSummary)
var i AnalyticsIssuesSummaryRow
err := row.Scan(&i.Total, &i.Resolved, &i.ResolutionRate)
return i, err
@ -164,36 +141,22 @@ SELECT
d.date,
COUNT(us.id)::bigint AS count
FROM generate_series(
$1::date,
$2::date,
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN user_subscriptions us ON us.created_at::date = d.date
AND ($3::timestamptz IS NULL OR us.created_at >= $3::timestamptz)
AND ($4::timestamptz IS NULL OR us.created_at < $4::timestamptz)
GROUP BY d.date
ORDER BY d.date
`
type AnalyticsNewSubscriptionsLast30DaysParams struct {
SeriesStart pgtype.Date `json:"series_start"`
SeriesEnd pgtype.Date `json:"series_end"`
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsNewSubscriptionsLast30DaysRow struct {
Date interface{} `json:"date"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsNewSubscriptionsLast30Days(ctx context.Context, arg AnalyticsNewSubscriptionsLast30DaysParams) ([]AnalyticsNewSubscriptionsLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNewSubscriptionsLast30Days,
arg.SeriesStart,
arg.SeriesEnd,
arg.RangeStart,
arg.RangeEnd,
)
func (q *Queries) AnalyticsNewSubscriptionsLast30Days(ctx context.Context) ([]AnalyticsNewSubscriptionsLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNewSubscriptionsLast30Days)
if err != nil {
return nil, err
}
@ -214,27 +177,20 @@ func (q *Queries) AnalyticsNewSubscriptionsLast30Days(ctx context.Context, arg A
const AnalyticsNotificationsByChannel = `-- name: AnalyticsNotificationsByChannel :many
SELECT
COALESCE(n.channel, 'unknown') AS channel,
COALESCE(channel, 'unknown') AS channel,
COUNT(*)::bigint AS count
FROM notifications n
WHERE ($1::timestamptz IS NULL OR n.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR n.created_at < $2::timestamptz)
GROUP BY n.channel
FROM notifications
GROUP BY channel
ORDER BY count DESC
`
type AnalyticsNotificationsByChannelParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsNotificationsByChannelRow struct {
Channel string `json:"channel"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsNotificationsByChannel(ctx context.Context, arg AnalyticsNotificationsByChannelParams) ([]AnalyticsNotificationsByChannelRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNotificationsByChannel, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsNotificationsByChannel(ctx context.Context) ([]AnalyticsNotificationsByChannelRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNotificationsByChannel)
if err != nil {
return nil, err
}
@ -255,27 +211,20 @@ func (q *Queries) AnalyticsNotificationsByChannel(ctx context.Context, arg Analy
const AnalyticsNotificationsByType = `-- name: AnalyticsNotificationsByType :many
SELECT
COALESCE(n.type, 'unknown') AS type,
COALESCE(type, 'unknown') AS type,
COUNT(*)::bigint AS count
FROM notifications n
WHERE ($1::timestamptz IS NULL OR n.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR n.created_at < $2::timestamptz)
GROUP BY n.type
FROM notifications
GROUP BY type
ORDER BY count DESC
`
type AnalyticsNotificationsByTypeParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsNotificationsByTypeRow struct {
Type string `json:"type"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsNotificationsByType(ctx context.Context, arg AnalyticsNotificationsByTypeParams) ([]AnalyticsNotificationsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNotificationsByType, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsNotificationsByType(ctx context.Context) ([]AnalyticsNotificationsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNotificationsByType)
if err != nil {
return nil, err
}
@ -298,18 +247,11 @@ const AnalyticsNotificationsSummary = `-- name: AnalyticsNotificationsSummary :o
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE n.is_read = TRUE)::bigint AS read,
COUNT(*) FILTER (WHERE n.is_read = FALSE)::bigint AS unread
FROM notifications n
WHERE ($1::timestamptz IS NULL OR n.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR n.created_at < $2::timestamptz)
COUNT(*) FILTER (WHERE is_read = TRUE)::bigint AS read,
COUNT(*) FILTER (WHERE is_read = FALSE)::bigint AS unread
FROM notifications
`
type AnalyticsNotificationsSummaryParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsNotificationsSummaryRow struct {
Total int64 `json:"total"`
Read int64 `json:"read"`
@ -319,8 +261,8 @@ type AnalyticsNotificationsSummaryRow struct {
// =====================
// Notification Analytics
// =====================
func (q *Queries) AnalyticsNotificationsSummary(ctx context.Context, arg AnalyticsNotificationsSummaryParams) (AnalyticsNotificationsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsNotificationsSummary, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsNotificationsSummary(ctx context.Context) (AnalyticsNotificationsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsNotificationsSummary)
var i AnalyticsNotificationsSummaryRow
err := row.Scan(&i.Total, &i.Read, &i.Unread)
return i, err
@ -328,30 +270,23 @@ func (q *Queries) AnalyticsNotificationsSummary(ctx context.Context, arg Analyti
const AnalyticsPaymentsByMethod = `-- name: AnalyticsPaymentsByMethod :many
SELECT
COALESCE(p.payment_method, 'unknown') AS payment_method,
COALESCE(payment_method, 'unknown') AS payment_method,
COUNT(*)::bigint AS count,
COALESCE(SUM(p.amount), 0)::float8 AS total_amount
FROM payments p
WHERE p.status = 'SUCCESS'
AND ($1::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < $2::timestamptz)
GROUP BY p.payment_method
COALESCE(SUM(amount), 0)::float8 AS total_amount
FROM payments
WHERE status = 'SUCCESS'
GROUP BY payment_method
ORDER BY count DESC
`
type AnalyticsPaymentsByMethodParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsPaymentsByMethodRow struct {
PaymentMethod string `json:"payment_method"`
Count int64 `json:"count"`
TotalAmount float64 `json:"total_amount"`
}
func (q *Queries) AnalyticsPaymentsByMethod(ctx context.Context, arg AnalyticsPaymentsByMethodParams) ([]AnalyticsPaymentsByMethodRow, error) {
rows, err := q.db.Query(ctx, AnalyticsPaymentsByMethod, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsPaymentsByMethod(ctx context.Context) ([]AnalyticsPaymentsByMethodRow, error) {
rows, err := q.db.Query(ctx, AnalyticsPaymentsByMethod)
if err != nil {
return nil, err
}
@ -372,29 +307,22 @@ func (q *Queries) AnalyticsPaymentsByMethod(ctx context.Context, arg AnalyticsPa
const AnalyticsPaymentsByStatus = `-- name: AnalyticsPaymentsByStatus :many
SELECT
COALESCE(p.status, 'unknown') AS status,
COALESCE(status, 'unknown') AS status,
COUNT(*)::bigint AS count,
COALESCE(SUM(p.amount), 0)::float8 AS total_amount
FROM payments p
WHERE ($1::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < $2::timestamptz)
GROUP BY p.status
COALESCE(SUM(amount), 0)::float8 AS total_amount
FROM payments
GROUP BY status
ORDER BY count DESC
`
type AnalyticsPaymentsByStatusParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsPaymentsByStatusRow struct {
Status string `json:"status"`
Count int64 `json:"count"`
TotalAmount float64 `json:"total_amount"`
}
func (q *Queries) AnalyticsPaymentsByStatus(ctx context.Context, arg AnalyticsPaymentsByStatusParams) ([]AnalyticsPaymentsByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsPaymentsByStatus, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsPaymentsByStatus(ctx context.Context) ([]AnalyticsPaymentsByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsPaymentsByStatus)
if err != nil {
return nil, err
}
@ -416,20 +344,13 @@ func (q *Queries) AnalyticsPaymentsByStatus(ctx context.Context, arg AnalyticsPa
const AnalyticsPaymentsSummary = `-- name: AnalyticsPaymentsSummary :one
SELECT
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'SUCCESS'), 0)::float8 AS total_revenue,
COALESCE(AVG(p.amount) FILTER (WHERE p.status = 'SUCCESS'), 0)::float8 AS avg_value,
COALESCE(SUM(amount) FILTER (WHERE status = 'SUCCESS'), 0)::float8 AS total_revenue,
COALESCE(AVG(amount) FILTER (WHERE status = 'SUCCESS'), 0)::float8 AS avg_value,
COUNT(*)::bigint AS total_payments,
COUNT(*) FILTER (WHERE p.status = 'SUCCESS')::bigint AS successful_payments
FROM payments p
WHERE ($1::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < $2::timestamptz)
COUNT(*) FILTER (WHERE status = 'SUCCESS')::bigint AS successful_payments
FROM payments
`
type AnalyticsPaymentsSummaryParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsPaymentsSummaryRow struct {
TotalRevenue float64 `json:"total_revenue"`
AvgValue float64 `json:"avg_value"`
@ -440,8 +361,8 @@ type AnalyticsPaymentsSummaryRow struct {
// =====================
// Payment Analytics
// =====================
func (q *Queries) AnalyticsPaymentsSummary(ctx context.Context, arg AnalyticsPaymentsSummaryParams) (AnalyticsPaymentsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsPaymentsSummary, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsPaymentsSummary(ctx context.Context) (AnalyticsPaymentsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsPaymentsSummary)
var i AnalyticsPaymentsSummaryRow
err := row.Scan(
&i.TotalRevenue,
@ -454,27 +375,20 @@ func (q *Queries) AnalyticsPaymentsSummary(ctx context.Context, arg AnalyticsPay
const AnalyticsQuestionSetsByType = `-- name: AnalyticsQuestionSetsByType :many
SELECT
COALESCE(qs.set_type, 'unknown') AS set_type,
COALESCE(set_type, 'unknown') AS set_type,
COUNT(*)::bigint AS count
FROM question_sets qs
WHERE ($1::timestamptz IS NULL OR qs.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR qs.created_at < $2::timestamptz)
GROUP BY qs.set_type
FROM question_sets
GROUP BY set_type
ORDER BY count DESC
`
type AnalyticsQuestionSetsByTypeParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsQuestionSetsByTypeRow struct {
SetType string `json:"set_type"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsQuestionSetsByType(ctx context.Context, arg AnalyticsQuestionSetsByTypeParams) ([]AnalyticsQuestionSetsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsQuestionSetsByType, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsQuestionSetsByType(ctx context.Context) ([]AnalyticsQuestionSetsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsQuestionSetsByType)
if err != nil {
return nil, err
}
@ -495,27 +409,20 @@ func (q *Queries) AnalyticsQuestionSetsByType(ctx context.Context, arg Analytics
const AnalyticsQuestionsByType = `-- name: AnalyticsQuestionsByType :many
SELECT
COALESCE(q.question_type, 'unknown') AS question_type,
COALESCE(question_type, 'unknown') AS question_type,
COUNT(*)::bigint AS count
FROM questions q
WHERE ($1::timestamptz IS NULL OR q.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR q.created_at < $2::timestamptz)
GROUP BY q.question_type
FROM questions
GROUP BY question_type
ORDER BY count DESC
`
type AnalyticsQuestionsByTypeParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsQuestionsByTypeRow struct {
QuestionType string `json:"question_type"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsQuestionsByType(ctx context.Context, arg AnalyticsQuestionsByTypeParams) ([]AnalyticsQuestionsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsQuestionsByType, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsQuestionsByType(ctx context.Context) ([]AnalyticsQuestionsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsQuestionsByType)
if err != nil {
return nil, err
}
@ -537,25 +444,10 @@ func (q *Queries) AnalyticsQuestionsByType(ctx context.Context, arg AnalyticsQue
const AnalyticsQuestionsCounts = `-- name: AnalyticsQuestionsCounts :one
SELECT
(
SELECT COUNT(*)::bigint
FROM questions q
WHERE ($1::timestamptz IS NULL OR q.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR q.created_at < $2::timestamptz)
) AS total_questions,
(
SELECT COUNT(*)::bigint
FROM question_sets qs
WHERE ($1::timestamptz IS NULL OR qs.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR qs.created_at < $2::timestamptz)
) AS total_question_sets
(SELECT COUNT(*)::bigint FROM questions) AS total_questions,
(SELECT COUNT(*)::bigint FROM question_sets) AS total_question_sets
`
type AnalyticsQuestionsCountsParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsQuestionsCountsRow struct {
TotalQuestions int64 `json:"total_questions"`
TotalQuestionSets int64 `json:"total_question_sets"`
@ -564,8 +456,8 @@ type AnalyticsQuestionsCountsRow struct {
// =====================
// Content Analytics
// =====================
func (q *Queries) AnalyticsQuestionsCounts(ctx context.Context, arg AnalyticsQuestionsCountsParams) (AnalyticsQuestionsCountsRow, error) {
row := q.db.QueryRow(ctx, AnalyticsQuestionsCounts, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsQuestionsCounts(ctx context.Context) (AnalyticsQuestionsCountsRow, error) {
row := q.db.QueryRow(ctx, AnalyticsQuestionsCounts)
var i AnalyticsQuestionsCountsRow
err := row.Scan(&i.TotalQuestions, &i.TotalQuestionSets)
return i, err
@ -580,17 +472,10 @@ SELECT
FROM payments p
JOIN subscription_plans sp ON sp.id = p.plan_id
WHERE p.status = 'SUCCESS'
AND ($1::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < $2::timestamptz)
GROUP BY sp.name, sp.currency
ORDER BY total_revenue DESC
`
type AnalyticsRevenueByPlanParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsRevenueByPlanRow struct {
PlanName string `json:"plan_name"`
Currency string `json:"currency"`
@ -598,8 +483,8 @@ type AnalyticsRevenueByPlanRow struct {
TotalRevenue float64 `json:"total_revenue"`
}
func (q *Queries) AnalyticsRevenueByPlan(ctx context.Context, arg AnalyticsRevenueByPlanParams) ([]AnalyticsRevenueByPlanRow, error) {
rows, err := q.db.Query(ctx, AnalyticsRevenueByPlan, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsRevenueByPlan(ctx context.Context) ([]AnalyticsRevenueByPlanRow, error) {
rows, err := q.db.Query(ctx, AnalyticsRevenueByPlan)
if err != nil {
return nil, err
}
@ -628,37 +513,22 @@ SELECT
d.date,
COALESCE(SUM(p.amount), 0)::float8 AS total_revenue
FROM generate_series(
$1::date,
$2::date,
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN payments p ON COALESCE(p.paid_at, p.created_at)::date = d.date
AND p.status = 'SUCCESS'
AND ($3::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= $3::timestamptz)
AND ($4::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < $4::timestamptz)
LEFT JOIN payments p ON p.paid_at::date = d.date AND p.status = 'SUCCESS'
GROUP BY d.date
ORDER BY d.date
`
type AnalyticsRevenueLast30DaysParams struct {
SeriesStart pgtype.Date `json:"series_start"`
SeriesEnd pgtype.Date `json:"series_end"`
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsRevenueLast30DaysRow struct {
Date interface{} `json:"date"`
TotalRevenue float64 `json:"total_revenue"`
}
func (q *Queries) AnalyticsRevenueLast30Days(ctx context.Context, arg AnalyticsRevenueLast30DaysParams) ([]AnalyticsRevenueLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsRevenueLast30Days,
arg.SeriesStart,
arg.SeriesEnd,
arg.RangeStart,
arg.RangeEnd,
)
func (q *Queries) AnalyticsRevenueLast30Days(ctx context.Context) ([]AnalyticsRevenueLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsRevenueLast30Days)
if err != nil {
return nil, err
}
@ -679,27 +549,20 @@ func (q *Queries) AnalyticsRevenueLast30Days(ctx context.Context, arg AnalyticsR
const AnalyticsSubscriptionsByStatus = `-- name: AnalyticsSubscriptionsByStatus :many
SELECT
COALESCE(us.status, 'unknown') AS status,
COALESCE(status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM user_subscriptions us
WHERE ($1::timestamptz IS NULL OR us.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR us.created_at < $2::timestamptz)
GROUP BY us.status
FROM user_subscriptions
GROUP BY status
ORDER BY count DESC
`
type AnalyticsSubscriptionsByStatusParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsSubscriptionsByStatusRow struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsSubscriptionsByStatus(ctx context.Context, arg AnalyticsSubscriptionsByStatusParams) ([]AnalyticsSubscriptionsByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsSubscriptionsByStatus, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsSubscriptionsByStatus(ctx context.Context) ([]AnalyticsSubscriptionsByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsSubscriptionsByStatus)
if err != nil {
return nil, err
}
@ -722,27 +585,13 @@ const AnalyticsSubscriptionsSummary = `-- name: AnalyticsSubscriptionsSummary :o
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE us.status = 'ACTIVE')::bigint AS active,
COUNT(*) FILTER (WHERE us.created_at::date = $1::date)::bigint AS new_today,
COUNT(*) FILTER (
WHERE us.created_at::date >= $1::date - INTERVAL '6 days'
AND us.created_at::date <= $1::date
)::bigint AS new_this_week,
COUNT(*) FILTER (
WHERE us.created_at::date >= $1::date - INTERVAL '29 days'
AND us.created_at::date <= $1::date
)::bigint AS new_this_month
FROM user_subscriptions us
WHERE ($2::timestamptz IS NULL OR us.created_at >= $2::timestamptz)
AND ($3::timestamptz IS NULL OR us.created_at < $3::timestamptz)
COUNT(*) FILTER (WHERE status = 'ACTIVE')::bigint AS active,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE)::bigint AS new_today,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '7 days')::bigint AS new_this_week,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '30 days')::bigint AS new_this_month
FROM user_subscriptions
`
type AnalyticsSubscriptionsSummaryParams struct {
RefDate pgtype.Date `json:"ref_date"`
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsSubscriptionsSummaryRow struct {
Total int64 `json:"total"`
Active int64 `json:"active"`
@ -754,8 +603,8 @@ type AnalyticsSubscriptionsSummaryRow struct {
// =====================
// Subscription Analytics
// =====================
func (q *Queries) AnalyticsSubscriptionsSummary(ctx context.Context, arg AnalyticsSubscriptionsSummaryParams) (AnalyticsSubscriptionsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsSubscriptionsSummary, arg.RefDate, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsSubscriptionsSummary(ctx context.Context) (AnalyticsSubscriptionsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsSubscriptionsSummary)
var i AnalyticsSubscriptionsSummaryRow
err := row.Scan(
&i.Total,
@ -769,27 +618,20 @@ func (q *Queries) AnalyticsSubscriptionsSummary(ctx context.Context, arg Analyti
const AnalyticsTeamByRole = `-- name: AnalyticsTeamByRole :many
SELECT
COALESCE(tm.team_role, 'unknown') AS team_role,
COALESCE(team_role, 'unknown') AS team_role,
COUNT(*)::bigint AS count
FROM team_members tm
WHERE ($1::timestamptz IS NULL OR tm.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR tm.created_at < $2::timestamptz)
GROUP BY tm.team_role
FROM team_members
GROUP BY team_role
ORDER BY count DESC
`
type AnalyticsTeamByRoleParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsTeamByRoleRow struct {
TeamRole string `json:"team_role"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsTeamByRole(ctx context.Context, arg AnalyticsTeamByRoleParams) ([]AnalyticsTeamByRoleRow, error) {
rows, err := q.db.Query(ctx, AnalyticsTeamByRole, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsTeamByRole(ctx context.Context) ([]AnalyticsTeamByRoleRow, error) {
rows, err := q.db.Query(ctx, AnalyticsTeamByRole)
if err != nil {
return nil, err
}
@ -810,27 +652,20 @@ func (q *Queries) AnalyticsTeamByRole(ctx context.Context, arg AnalyticsTeamByRo
const AnalyticsTeamByStatus = `-- name: AnalyticsTeamByStatus :many
SELECT
COALESCE(tm.status, 'unknown') AS status,
COALESCE(status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM team_members tm
WHERE ($1::timestamptz IS NULL OR tm.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR tm.created_at < $2::timestamptz)
GROUP BY tm.status
FROM team_members
GROUP BY status
ORDER BY count DESC
`
type AnalyticsTeamByStatusParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsTeamByStatusRow struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsTeamByStatus(ctx context.Context, arg AnalyticsTeamByStatusParams) ([]AnalyticsTeamByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsTeamByStatus, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsTeamByStatus(ctx context.Context) ([]AnalyticsTeamByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsTeamByStatus)
if err != nil {
return nil, err
}
@ -853,21 +688,14 @@ const AnalyticsTeamSummary = `-- name: AnalyticsTeamSummary :one
SELECT
COUNT(*)::bigint AS total_members
FROM team_members tm
WHERE ($1::timestamptz IS NULL OR tm.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR tm.created_at < $2::timestamptz)
FROM team_members
`
type AnalyticsTeamSummaryParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
// =====================
// Team Analytics
// =====================
func (q *Queries) AnalyticsTeamSummary(ctx context.Context, arg AnalyticsTeamSummaryParams) (int64, error) {
row := q.db.QueryRow(ctx, AnalyticsTeamSummary, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsTeamSummary(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, AnalyticsTeamSummary)
var total_members int64
err := row.Scan(&total_members)
return total_members, err
@ -878,36 +706,22 @@ SELECT
d.date,
COUNT(u.id)::bigint AS count
FROM generate_series(
$1::date,
$2::date,
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN users u ON u.created_at::date = d.date
AND ($3::timestamptz IS NULL OR u.created_at >= $3::timestamptz)
AND ($4::timestamptz IS NULL OR u.created_at < $4::timestamptz)
GROUP BY d.date
ORDER BY d.date
`
type AnalyticsUserRegistrationsLast30DaysParams struct {
SeriesStart pgtype.Date `json:"series_start"`
SeriesEnd pgtype.Date `json:"series_end"`
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUserRegistrationsLast30DaysRow struct {
Date interface{} `json:"date"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUserRegistrationsLast30Days(ctx context.Context, arg AnalyticsUserRegistrationsLast30DaysParams) ([]AnalyticsUserRegistrationsLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUserRegistrationsLast30Days,
arg.SeriesStart,
arg.SeriesEnd,
arg.RangeStart,
arg.RangeEnd,
)
func (q *Queries) AnalyticsUserRegistrationsLast30Days(ctx context.Context) ([]AnalyticsUserRegistrationsLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUserRegistrationsLast30Days)
if err != nil {
return nil, err
}
@ -928,27 +742,20 @@ func (q *Queries) AnalyticsUserRegistrationsLast30Days(ctx context.Context, arg
const AnalyticsUsersByAgeGroup = `-- name: AnalyticsUsersByAgeGroup :many
SELECT
COALESCE(u.age_group, 'unknown') AS age_group,
COALESCE(age_group, 'unknown') AS age_group,
COUNT(*)::bigint AS count
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY u.age_group
FROM users
GROUP BY age_group
ORDER BY count DESC
`
type AnalyticsUsersByAgeGroupParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByAgeGroupRow struct {
AgeGroup string `json:"age_group"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByAgeGroup(ctx context.Context, arg AnalyticsUsersByAgeGroupParams) ([]AnalyticsUsersByAgeGroupRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByAgeGroup, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsUsersByAgeGroup(ctx context.Context) ([]AnalyticsUsersByAgeGroupRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByAgeGroup)
if err != nil {
return nil, err
}
@ -969,27 +776,20 @@ func (q *Queries) AnalyticsUsersByAgeGroup(ctx context.Context, arg AnalyticsUse
const AnalyticsUsersByKnowledgeLevel = `-- name: AnalyticsUsersByKnowledgeLevel :many
SELECT
COALESCE(u.knowledge_level, 'unknown') AS knowledge_level,
COALESCE(knowledge_level, 'unknown') AS knowledge_level,
COUNT(*)::bigint AS count
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY u.knowledge_level
FROM users
GROUP BY knowledge_level
ORDER BY count DESC
`
type AnalyticsUsersByKnowledgeLevelParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByKnowledgeLevelRow struct {
KnowledgeLevel string `json:"knowledge_level"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByKnowledgeLevel(ctx context.Context, arg AnalyticsUsersByKnowledgeLevelParams) ([]AnalyticsUsersByKnowledgeLevelRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByKnowledgeLevel, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsUsersByKnowledgeLevel(ctx context.Context) ([]AnalyticsUsersByKnowledgeLevelRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByKnowledgeLevel)
if err != nil {
return nil, err
}
@ -1010,27 +810,20 @@ func (q *Queries) AnalyticsUsersByKnowledgeLevel(ctx context.Context, arg Analyt
const AnalyticsUsersByRegion = `-- name: AnalyticsUsersByRegion :many
SELECT
COALESCE(u.region, 'unknown') AS region,
COALESCE(region, 'unknown') AS region,
COUNT(*)::bigint AS count
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY u.region
FROM users
GROUP BY region
ORDER BY count DESC
`
type AnalyticsUsersByRegionParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByRegionRow struct {
Region string `json:"region"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByRegion(ctx context.Context, arg AnalyticsUsersByRegionParams) ([]AnalyticsUsersByRegionRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByRegion, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsUsersByRegion(ctx context.Context) ([]AnalyticsUsersByRegionRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByRegion)
if err != nil {
return nil, err
}
@ -1051,27 +844,20 @@ func (q *Queries) AnalyticsUsersByRegion(ctx context.Context, arg AnalyticsUsers
const AnalyticsUsersByRole = `-- name: AnalyticsUsersByRole :many
SELECT
COALESCE(u.role, 'unknown') AS role,
COALESCE(role, 'unknown') AS role,
COUNT(*)::bigint AS count
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY u.role
FROM users
GROUP BY role
ORDER BY count DESC
`
type AnalyticsUsersByRoleParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByRoleRow struct {
Role string `json:"role"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByRole(ctx context.Context, arg AnalyticsUsersByRoleParams) ([]AnalyticsUsersByRoleRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByRole, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsUsersByRole(ctx context.Context) ([]AnalyticsUsersByRoleRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByRole)
if err != nil {
return nil, err
}
@ -1092,27 +878,20 @@ func (q *Queries) AnalyticsUsersByRole(ctx context.Context, arg AnalyticsUsersBy
const AnalyticsUsersByStatus = `-- name: AnalyticsUsersByStatus :many
SELECT
COALESCE(u.status, 'unknown') AS status,
COALESCE(status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY u.status
FROM users
GROUP BY status
ORDER BY count DESC
`
type AnalyticsUsersByStatusParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByStatusRow struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByStatus(ctx context.Context, arg AnalyticsUsersByStatusParams) ([]AnalyticsUsersByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByStatus, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsUsersByStatus(ctx context.Context) ([]AnalyticsUsersByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByStatus)
if err != nil {
return nil, err
}
@ -1136,26 +915,12 @@ const AnalyticsUsersSummary = `-- name: AnalyticsUsersSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE u.created_at::date = $1::date)::bigint AS new_today,
COUNT(*) FILTER (
WHERE u.created_at::date >= $1::date - INTERVAL '6 days'
AND u.created_at::date <= $1::date
)::bigint AS new_this_week,
COUNT(*) FILTER (
WHERE u.created_at::date >= $1::date - INTERVAL '29 days'
AND u.created_at::date <= $1::date
)::bigint AS new_this_month
FROM users u
WHERE ($2::timestamptz IS NULL OR u.created_at >= $2::timestamptz)
AND ($3::timestamptz IS NULL OR u.created_at < $3::timestamptz)
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE)::bigint AS new_today,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '7 days')::bigint AS new_this_week,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '30 days')::bigint AS new_this_month
FROM users
`
type AnalyticsUsersSummaryParams struct {
RefDate pgtype.Date `json:"ref_date"`
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersSummaryRow struct {
Total int64 `json:"total"`
NewToday int64 `json:"new_today"`
@ -1164,25 +929,13 @@ type AnalyticsUsersSummaryRow struct {
}
// =====================
// Analytics (date-filtered)
// Analytics
// =====================
// Shared optional params (nullable = all-time):
//
// range_start, range_end (exclusive upper bound)
//
// Required chart params:
//
// series_start, series_end (inclusive dates)
//
// Relative window anchor:
//
// ref_date (inclusive date used for new_today/week/month)
//
// =====================
// User Analytics
// =====================
func (q *Queries) AnalyticsUsersSummary(ctx context.Context, arg AnalyticsUsersSummaryParams) (AnalyticsUsersSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsUsersSummary, arg.RefDate, arg.RangeStart, arg.RangeEnd)
func (q *Queries) AnalyticsUsersSummary(ctx context.Context) (AnalyticsUsersSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsUsersSummary)
var i AnalyticsUsersSummaryRow
err := row.Scan(
&i.Total,

View File

@ -101,17 +101,6 @@ type ExamPrepUnitModuleLesson struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Faq struct {
ID int64 `json:"id"`
Question string `json:"question"`
Answer string `json:"answer"`
Category pgtype.Text `json:"category"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type GlobalSetting struct {
Key string `json:"key"`
Value string `json:"value"`

View File

@ -110,7 +110,6 @@ type AnalyticsTeamSection struct {
type AnalyticsDashboard struct {
GeneratedAt time.Time `json:"generated_at"`
DateFilter AnalyticsDateFilter `json:"date_filter"`
Users AnalyticsUsersSection `json:"users"`
Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"`
Payments AnalyticsPaymentsSection `json:"payments"`

View File

@ -1,158 +0,0 @@
package domain
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
const (
AnalyticsFilterAllTime = "all_time"
AnalyticsFilterYear = "year"
AnalyticsFilterYearMonth = "year_month"
AnalyticsFilterCustom = "custom"
)
// AnalyticsDateFilter describes the effective reporting window for dashboard analytics.
type AnalyticsDateFilter struct {
Mode string `json:"mode"`
Year *int `json:"year,omitempty"`
Month *int `json:"month,omitempty"`
From *time.Time `json:"from,omitempty"`
To *time.Time `json:"to,omitempty"`
RangeStart *time.Time `json:"range_start,omitempty"`
RangeEnd *time.Time `json:"range_end,omitempty"`
SeriesStart time.Time `json:"series_start"`
SeriesEnd time.Time `json:"series_end"`
RefDate time.Time `json:"ref_date"`
}
// ParseAnalyticsDateFilter resolves dashboard date filters from query params.
//
// Supported:
// - (none) => all-time totals; charts default to last 30 days
// - year=2025 => calendar year
// - year=2025&month=3 => calendar month
// - from=YYYY-MM-DD&to=YYYY-MM-DD => inclusive custom range (to required with from)
func ParseAnalyticsDateFilter(c *fiber.Ctx) (AnalyticsDateFilter, error) {
now := time.Now().UTC()
today := dateOnlyUTC(now)
fromRaw := strings.TrimSpace(c.Query("from"))
toRaw := strings.TrimSpace(c.Query("to"))
yearRaw := strings.TrimSpace(c.Query("year"))
monthRaw := strings.TrimSpace(c.Query("month"))
if fromRaw != "" || toRaw != "" {
if fromRaw == "" || toRaw == "" {
return AnalyticsDateFilter{}, fmt.Errorf("both from and to are required for a custom date range")
}
from, err := parseAnalyticsDate(fromRaw)
if err != nil {
return AnalyticsDateFilter{}, fmt.Errorf("invalid from date: %w", err)
}
to, err := parseAnalyticsDate(toRaw)
if err != nil {
return AnalyticsDateFilter{}, fmt.Errorf("invalid to date: %w", err)
}
if to.Before(from) {
return AnalyticsDateFilter{}, fmt.Errorf("to must be on or after from")
}
rangeStart := from
rangeEnd := to.AddDate(0, 0, 1)
ref := to
if today.Before(to) {
ref = today
}
return AnalyticsDateFilter{
Mode: AnalyticsFilterCustom,
From: &from,
To: &to,
RangeStart: &rangeStart,
RangeEnd: &rangeEnd,
SeriesStart: from,
SeriesEnd: to,
RefDate: ref,
}, nil
}
if yearRaw != "" {
year, err := strconv.Atoi(yearRaw)
if err != nil || year < 2000 || year > 2100 {
return AnalyticsDateFilter{}, fmt.Errorf("year must be between 2000 and 2100")
}
if monthRaw == "" {
start := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC)
endExclusive := start.AddDate(1, 0, 0)
lastDay := endExclusive.AddDate(0, 0, -1)
ref := lastDay
if today.Year() == year && !today.After(lastDay) {
ref = today
}
return AnalyticsDateFilter{
Mode: AnalyticsFilterYear,
Year: &year,
RangeStart: &start,
RangeEnd: &endExclusive,
SeriesStart: start,
SeriesEnd: lastDay,
RefDate: ref,
}, nil
}
month, err := strconv.Atoi(monthRaw)
if err != nil || month < 1 || month > 12 {
return AnalyticsDateFilter{}, fmt.Errorf("month must be between 1 and 12")
}
start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
endExclusive := start.AddDate(0, 1, 0)
lastDay := endExclusive.AddDate(0, 0, -1)
ref := lastDay
if today.Year() == year && int(today.Month()) == month && !today.After(lastDay) {
ref = today
}
return AnalyticsDateFilter{
Mode: AnalyticsFilterYearMonth,
Year: &year,
Month: &month,
RangeStart: &start,
RangeEnd: &endExclusive,
SeriesStart: start,
SeriesEnd: lastDay,
RefDate: ref,
}, nil
}
if monthRaw != "" {
return AnalyticsDateFilter{}, fmt.Errorf("month requires year")
}
seriesEnd := today
seriesStart := seriesEnd.AddDate(0, 0, -29)
return AnalyticsDateFilter{
Mode: AnalyticsFilterAllTime,
SeriesStart: seriesStart,
SeriesEnd: seriesEnd,
RefDate: today,
}, nil
}
func parseAnalyticsDate(raw string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
return dateOnlyUTC(t), nil
}
t, err := time.Parse("2006-01-02", raw)
if err != nil {
return time.Time{}, err
}
return dateOnlyUTC(t), nil
}
func dateOnlyUTC(t time.Time) time.Time {
t = t.UTC()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}

View File

@ -1,78 +0,0 @@
package domain
import (
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
)
func TestParseAnalyticsDateFilter_allTime(t *testing.T) {
app := fiber.New()
var got AnalyticsDateFilter
app.Get("/", func(c *fiber.Ctx) error {
var err error
got, err = ParseAnalyticsDateFilter(c)
return err
})
req := httptest.NewRequest("GET", "/", nil)
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if got.Mode != AnalyticsFilterAllTime {
t.Fatalf("mode=%q", got.Mode)
}
if got.RangeStart != nil || got.RangeEnd != nil {
t.Fatal("expected no range bounds for all-time")
}
}
func TestParseAnalyticsDateFilter_yearMonth(t *testing.T) {
app := fiber.New()
var got AnalyticsDateFilter
app.Get("/", func(c *fiber.Ctx) error {
var err error
got, err = ParseAnalyticsDateFilter(c)
return err
})
req := httptest.NewRequest("GET", "/?year=2025&month=3", nil)
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if got.Mode != AnalyticsFilterYearMonth {
t.Fatalf("mode=%q", got.Mode)
}
if got.RangeStart == nil || got.RangeEnd == nil {
t.Fatal("expected range bounds")
}
wantStart := time.Date(2025, time.March, 1, 0, 0, 0, 0, time.UTC)
wantEnd := time.Date(2025, time.April, 1, 0, 0, 0, 0, time.UTC)
if !got.RangeStart.Equal(wantStart) || !got.RangeEnd.Equal(wantEnd) {
t.Fatalf("range=%v..%v", got.RangeStart, got.RangeEnd)
}
}
func TestParseAnalyticsDateFilter_custom(t *testing.T) {
app := fiber.New()
var got AnalyticsDateFilter
app.Get("/", func(c *fiber.Ctx) error {
var err error
got, err = ParseAnalyticsDateFilter(c)
return err
})
req := httptest.NewRequest("GET", "/?from=2025-01-10&to=2025-01-20", nil)
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if got.Mode != AnalyticsFilterCustom {
t.Fatalf("mode=%q", got.Mode)
}
wantEnd := time.Date(2025, time.January, 21, 0, 0, 0, 0, time.UTC)
if got.RangeEnd == nil || !got.RangeEnd.Equal(wantEnd) {
t.Fatalf("range_end=%v", got.RangeEnd)
}
}

View File

@ -32,7 +32,7 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
return err
}
if err := s.cascadeLMSCompletion(ctx, q, userID, &mod.ID, crs.ID, crs.ProgramID); err != nil {
if err := s.cascadeLMSCompletion(ctx, q, userID, mod.ID, crs.ID, crs.ProgramID); err != nil {
return err
}
@ -62,43 +62,21 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
if err != nil {
return err
}
var (
moduleID *int64
courseID int64
)
switch {
case scope.ModuleID.Valid:
mid := scope.ModuleID.Int64
moduleID = &mid
mod, err := q.GetModuleByID(ctx, mid)
if err != nil {
return err
}
courseID = mod.CourseID
case scope.LessonID.Valid:
lesson, err := q.GetLessonByID(ctx, scope.LessonID.Int64)
if err != nil {
return err
}
mid := lesson.ModuleID
moduleID = &mid
mod, err := q.GetModuleByID(ctx, mid)
if err != nil {
return err
}
courseID = mod.CourseID
case scope.CourseID.Valid:
courseID = scope.CourseID.Int64
default:
return fmt.Errorf("practice %d is not linked to lesson/module/course", questionSetID)
if !scope.ModuleID.Valid {
return fmt.Errorf("practice %d is not linked to a module", questionSetID)
}
crs, err := q.GetCourseByID(ctx, courseID)
mod, err := q.GetModuleByID(ctx, scope.ModuleID.Int64)
if err != nil {
return err
}
if err := s.cascadeLMSCompletion(ctx, q, userID, moduleID, crs.ID, crs.ProgramID); err != nil {
crs, err := q.GetCourseByID(ctx, mod.CourseID)
if err != nil {
return err
}
if err := s.cascadeLMSCompletion(ctx, q, userID, mod.ID, crs.ID, crs.ProgramID); err != nil {
return err
}
@ -108,40 +86,38 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
return nil
}
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID int64, moduleID *int64, courseID, programID int64) error {
if moduleID != nil {
moduleLessonsTotal, err := q.CountLessonsInModule(ctx, *moduleID)
if err != nil {
return err
}
moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: *moduleID,
UserID: userID,
})
if err != nil {
return err
}
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID))
if err != nil {
return err
}
modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
ModuleID: toPgInt8(moduleID),
UserID: userID,
})
if err != nil {
return err
}
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID, moduleID, courseID, programID int64) error {
moduleLessonsTotal, err := q.CountLessonsInModule(ctx, moduleID)
if err != nil {
return err
}
moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: moduleID,
UserID: userID,
})
if err != nil {
return err
}
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
if err != nil {
return err
}
modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
ModuleID: toPgInt8(&moduleID),
UserID: userID,
})
if err != nil {
return err
}
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal
if !moduleLessonsComplete || !modulePracticesComplete {
return nil
}
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal
if !moduleLessonsComplete || !modulePracticesComplete {
return nil
}
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: *moduleID}); err != nil {
return err
}
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: moduleID}); err != nil {
return err
}
nMods, err := q.CountModulesInCourse(ctx, courseID)

View File

@ -15,155 +15,141 @@ func toTime(v interface{}) time.Time {
return time.Time{}
}
// GetAnalyticsDashboard godoc
// @Summary Analytics dashboard
// @Description Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range.
// @Tags analytics
// @Produce json
// @Param year query int false "Calendar year (e.g. 2025)"
// @Param month query int false "Calendar month 1-12 (requires year)"
// @Param from query string false "Custom range start (YYYY-MM-DD or RFC3339)"
// @Param to query string false "Custom range end (YYYY-MM-DD or RFC3339, inclusive)"
// @Success 200 {object} domain.AnalyticsDashboard
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/analytics/dashboard [get]
func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
filter, err := domain.ParseAnalyticsDateFilter(c)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid date filter",
Error: err.Error(),
})
}
ctx := c.Context()
p := newAnalyticsQueryParams(filter)
usersSummary, err := h.analyticsDB.AnalyticsUsersSummary(ctx, p.UsersSummary)
// ── Users ──
usersSummary, err := h.analyticsDB.AnalyticsUsersSummary(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user analytics")
}
usersByRole, err := h.analyticsDB.AnalyticsUsersByRole(ctx, p.UsersByRole)
usersByRole, err := h.analyticsDB.AnalyticsUsersByRole(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by role")
}
usersByStatus, err := h.analyticsDB.AnalyticsUsersByStatus(ctx, p.UsersByStatus)
usersByStatus, err := h.analyticsDB.AnalyticsUsersByStatus(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by status")
}
usersByAge, err := h.analyticsDB.AnalyticsUsersByAgeGroup(ctx, p.UsersByAgeGroup)
usersByAge, err := h.analyticsDB.AnalyticsUsersByAgeGroup(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by age group")
}
usersByKnowledge, err := h.analyticsDB.AnalyticsUsersByKnowledgeLevel(ctx, p.UsersByKnowledgeLevel)
usersByKnowledge, err := h.analyticsDB.AnalyticsUsersByKnowledgeLevel(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by knowledge level")
}
usersByRegion, err := h.analyticsDB.AnalyticsUsersByRegion(ctx, p.UsersByRegion)
usersByRegion, err := h.analyticsDB.AnalyticsUsersByRegion(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by region")
}
userRegs, err := h.analyticsDB.AnalyticsUserRegistrationsLast30Days(ctx, p.UserRegistrationsSeries)
userRegs, err := h.analyticsDB.AnalyticsUserRegistrationsLast30Days(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user registrations")
}
subsSummary, err := h.analyticsDB.AnalyticsSubscriptionsSummary(ctx, p.SubscriptionsSummary)
// ── Subscriptions ──
subsSummary, err := h.analyticsDB.AnalyticsSubscriptionsSummary(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch subscription analytics")
}
subsByStatus, err := h.analyticsDB.AnalyticsSubscriptionsByStatus(ctx, p.SubscriptionsByStatus)
subsByStatus, err := h.analyticsDB.AnalyticsSubscriptionsByStatus(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch subscriptions by status")
}
revenueByPlan, err := h.analyticsDB.AnalyticsRevenueByPlan(ctx, p.RevenueByPlan)
revenueByPlan, err := h.analyticsDB.AnalyticsRevenueByPlan(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue by plan")
}
newSubs30, err := h.analyticsDB.AnalyticsNewSubscriptionsLast30Days(ctx, p.NewSubscriptionsSeries)
newSubs30, err := h.analyticsDB.AnalyticsNewSubscriptionsLast30Days(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch new subscriptions time series")
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch new subscriptions last 30 days")
}
paymentsSummary, err := h.analyticsDB.AnalyticsPaymentsSummary(ctx, p.PaymentsSummary)
// ── Payments ──
paymentsSummary, err := h.analyticsDB.AnalyticsPaymentsSummary(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payment analytics")
}
paymentsByStatus, err := h.analyticsDB.AnalyticsPaymentsByStatus(ctx, p.PaymentsByStatus)
paymentsByStatus, err := h.analyticsDB.AnalyticsPaymentsByStatus(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payments by status")
}
paymentsByMethod, err := h.analyticsDB.AnalyticsPaymentsByMethod(ctx, p.PaymentsByMethod)
paymentsByMethod, err := h.analyticsDB.AnalyticsPaymentsByMethod(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payments by method")
}
revenue30, err := h.analyticsDB.AnalyticsRevenueLast30Days(ctx, p.RevenueSeries)
revenue30, err := h.analyticsDB.AnalyticsRevenueLast30Days(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue time series")
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue last 30 days")
}
// ── Courses ──
courseCounts, err := h.analyticsDB.AnalyticsCourseCounts(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch course analytics")
}
questionsCounts, err := h.analyticsDB.AnalyticsQuestionsCounts(ctx, p.QuestionsCounts)
// ── Content ──
questionsCounts, err := h.analyticsDB.AnalyticsQuestionsCounts(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch content analytics")
}
questionsByType, err := h.analyticsDB.AnalyticsQuestionsByType(ctx, p.QuestionsByType)
questionsByType, err := h.analyticsDB.AnalyticsQuestionsByType(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch questions by type")
}
questionSetsByType, err := h.analyticsDB.AnalyticsQuestionSetsByType(ctx, p.QuestionSetsByType)
questionSetsByType, err := h.analyticsDB.AnalyticsQuestionSetsByType(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch question sets by type")
}
notifSummary, err := h.analyticsDB.AnalyticsNotificationsSummary(ctx, p.NotificationsSummary)
// ── Notifications ──
notifSummary, err := h.analyticsDB.AnalyticsNotificationsSummary(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notification analytics")
}
notifByChannel, err := h.analyticsDB.AnalyticsNotificationsByChannel(ctx, p.NotificationsChannel)
notifByChannel, err := h.analyticsDB.AnalyticsNotificationsByChannel(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications by channel")
}
notifByType, err := h.analyticsDB.AnalyticsNotificationsByType(ctx, p.NotificationsType)
notifByType, err := h.analyticsDB.AnalyticsNotificationsByType(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications by type")
}
issuesSummary, err := h.analyticsDB.AnalyticsIssuesSummary(ctx, p.IssuesSummary)
// ── Issues ──
issuesSummary, err := h.analyticsDB.AnalyticsIssuesSummary(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issue analytics")
}
issuesByStatus, err := h.analyticsDB.AnalyticsIssuesByStatus(ctx, p.IssuesByStatus)
issuesByStatus, err := h.analyticsDB.AnalyticsIssuesByStatus(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issues by status")
}
issuesByType, err := h.analyticsDB.AnalyticsIssuesByType(ctx, p.IssuesByType)
issuesByType, err := h.analyticsDB.AnalyticsIssuesByType(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issues by type")
}
teamTotal, err := h.analyticsDB.AnalyticsTeamSummary(ctx, p.TeamSummary)
// ── Team ──
teamTotal, err := h.analyticsDB.AnalyticsTeamSummary(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team analytics")
}
teamByRole, err := h.analyticsDB.AnalyticsTeamByRole(ctx, p.TeamByRole)
teamByRole, err := h.analyticsDB.AnalyticsTeamByRole(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by role")
}
teamByStatus, err := h.analyticsDB.AnalyticsTeamByStatus(ctx, p.TeamByStatus)
teamByStatus, err := h.analyticsDB.AnalyticsTeamByStatus(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by status")
}
// ── Map to domain types ──
dashboard := domain.AnalyticsDashboard{
GeneratedAt: time.Now().UTC(),
DateFilter: filter,
Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs),
GeneratedAt: time.Now(),
Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs),
Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30),
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30),
Courses: domain.AnalyticsCoursesSection{

View File

@ -1,113 +0,0 @@
package handlers
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
type analyticsQueryParams struct {
UsersSummary dbgen.AnalyticsUsersSummaryParams
UsersByRole dbgen.AnalyticsUsersByRoleParams
UsersByStatus dbgen.AnalyticsUsersByStatusParams
UsersByAgeGroup dbgen.AnalyticsUsersByAgeGroupParams
UsersByKnowledgeLevel dbgen.AnalyticsUsersByKnowledgeLevelParams
UsersByRegion dbgen.AnalyticsUsersByRegionParams
UserRegistrationsSeries dbgen.AnalyticsUserRegistrationsLast30DaysParams
SubscriptionsSummary dbgen.AnalyticsSubscriptionsSummaryParams
SubscriptionsByStatus dbgen.AnalyticsSubscriptionsByStatusParams
RevenueByPlan dbgen.AnalyticsRevenueByPlanParams
NewSubscriptionsSeries dbgen.AnalyticsNewSubscriptionsLast30DaysParams
PaymentsSummary dbgen.AnalyticsPaymentsSummaryParams
PaymentsByStatus dbgen.AnalyticsPaymentsByStatusParams
PaymentsByMethod dbgen.AnalyticsPaymentsByMethodParams
RevenueSeries dbgen.AnalyticsRevenueLast30DaysParams
QuestionsCounts dbgen.AnalyticsQuestionsCountsParams
QuestionsByType dbgen.AnalyticsQuestionsByTypeParams
QuestionSetsByType dbgen.AnalyticsQuestionSetsByTypeParams
NotificationsSummary dbgen.AnalyticsNotificationsSummaryParams
NotificationsChannel dbgen.AnalyticsNotificationsByChannelParams
NotificationsType dbgen.AnalyticsNotificationsByTypeParams
IssuesSummary dbgen.AnalyticsIssuesSummaryParams
IssuesByStatus dbgen.AnalyticsIssuesByStatusParams
IssuesByType dbgen.AnalyticsIssuesByTypeParams
TeamSummary dbgen.AnalyticsTeamSummaryParams
TeamByRole dbgen.AnalyticsTeamByRoleParams
TeamByStatus dbgen.AnalyticsTeamByStatusParams
}
func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams {
ref := pgAnalyticsDate(f.RefDate)
rs, re := pgAnalyticsTimestamptzPtr(f.RangeStart), pgAnalyticsTimestamptzPtr(f.RangeEnd)
series := dbgen.AnalyticsUserRegistrationsLast30DaysParams{
SeriesStart: pgAnalyticsDate(f.SeriesStart),
SeriesEnd: pgAnalyticsDate(f.SeriesEnd),
RangeStart: rs,
RangeEnd: re,
}
return analyticsQueryParams{
UsersSummary: dbgen.AnalyticsUsersSummaryParams{RefDate: ref, RangeStart: rs, RangeEnd: re},
UsersByRole: dbgen.AnalyticsUsersByRoleParams{RangeStart: rs, RangeEnd: re},
UsersByStatus: dbgen.AnalyticsUsersByStatusParams{RangeStart: rs, RangeEnd: re},
UsersByAgeGroup: dbgen.AnalyticsUsersByAgeGroupParams{RangeStart: rs, RangeEnd: re},
UsersByKnowledgeLevel: dbgen.AnalyticsUsersByKnowledgeLevelParams{RangeStart: rs, RangeEnd: re},
UsersByRegion: dbgen.AnalyticsUsersByRegionParams{RangeStart: rs, RangeEnd: re},
UserRegistrationsSeries: series,
SubscriptionsSummary: dbgen.AnalyticsSubscriptionsSummaryParams{RefDate: ref, RangeStart: rs, RangeEnd: re},
SubscriptionsByStatus: dbgen.AnalyticsSubscriptionsByStatusParams{RangeStart: rs, RangeEnd: re},
RevenueByPlan: dbgen.AnalyticsRevenueByPlanParams{RangeStart: rs, RangeEnd: re},
NewSubscriptionsSeries: dbgen.AnalyticsNewSubscriptionsLast30DaysParams{
SeriesStart: series.SeriesStart,
SeriesEnd: series.SeriesEnd,
RangeStart: rs,
RangeEnd: re,
},
PaymentsSummary: dbgen.AnalyticsPaymentsSummaryParams{RangeStart: rs, RangeEnd: re},
PaymentsByStatus: dbgen.AnalyticsPaymentsByStatusParams{RangeStart: rs, RangeEnd: re},
PaymentsByMethod: dbgen.AnalyticsPaymentsByMethodParams{RangeStart: rs, RangeEnd: re},
RevenueSeries: dbgen.AnalyticsRevenueLast30DaysParams{
SeriesStart: series.SeriesStart,
SeriesEnd: series.SeriesEnd,
RangeStart: rs,
RangeEnd: re,
},
QuestionsCounts: dbgen.AnalyticsQuestionsCountsParams{RangeStart: rs, RangeEnd: re},
QuestionsByType: dbgen.AnalyticsQuestionsByTypeParams{RangeStart: rs, RangeEnd: re},
QuestionSetsByType: dbgen.AnalyticsQuestionSetsByTypeParams{RangeStart: rs, RangeEnd: re},
NotificationsSummary: dbgen.AnalyticsNotificationsSummaryParams{RangeStart: rs, RangeEnd: re},
NotificationsChannel: dbgen.AnalyticsNotificationsByChannelParams{RangeStart: rs, RangeEnd: re},
NotificationsType: dbgen.AnalyticsNotificationsByTypeParams{RangeStart: rs, RangeEnd: re},
IssuesSummary: dbgen.AnalyticsIssuesSummaryParams{RangeStart: rs, RangeEnd: re},
IssuesByStatus: dbgen.AnalyticsIssuesByStatusParams{RangeStart: rs, RangeEnd: re},
IssuesByType: dbgen.AnalyticsIssuesByTypeParams{RangeStart: rs, RangeEnd: re},
TeamSummary: dbgen.AnalyticsTeamSummaryParams{RangeStart: rs, RangeEnd: re},
TeamByRole: dbgen.AnalyticsTeamByRoleParams{RangeStart: rs, RangeEnd: re},
TeamByStatus: dbgen.AnalyticsTeamByStatusParams{RangeStart: rs, RangeEnd: re},
}
}
func pgAnalyticsDate(t time.Time) pgtype.Date {
return pgtype.Date{Time: t.UTC(), Valid: true}
}
func pgAnalyticsTimestamptzPtr(t *time.Time) pgtype.Timestamptz {
if t == nil {
return pgtype.Timestamptz{Valid: false}
}
return pgtype.Timestamptz{Time: t.UTC(), Valid: true}
}

View File

@ -1547,34 +1547,35 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
})
}
// Prefer LMS practice ID resolution to avoid accidental collisions with question_set IDs.
practice, practiceErr := h.practiceSvc.GetByID(c.Context(), id)
var set domain.QuestionSet
var setErr error
if practiceErr == nil {
set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID)
} else {
// Backward compatibility: also accept question_set.id directly.
set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), id)
}
if setErr != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: setErr.Error(),
})
}
if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"})
}
// Enforce sequential gating only for published practices.
if strings.EqualFold(set.Status, "PUBLISHED") {
if err := h.enforcePracticeSequenceForStudent(c, set); err != nil {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "You must complete previous practices first",
set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), id)
if err != nil {
// Backward/UX compatibility: accept either question_set.id or lms_practices.id.
practice, practiceErr := h.practiceSvc.GetByID(c.Context(), id)
if practiceErr != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
}
set, err = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
}
}
if !isSequenceGatedPractice(set) || !strings.EqualFold(set.Status, "PUBLISHED") {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
})
}
if err := h.enforcePracticeSequenceForStudent(c, set); err != nil {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "You must complete previous practices first",
Error: err.Error(),
})
}
if err := h.lmsProgressSvc.CompletePracticeForUser(c.Context(), userID, set.ID); err != nil {