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

View File

@ -101,17 +101,6 @@ type ExamPrepUnitModuleLesson struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` 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 { type GlobalSetting struct {
Key string `json:"key"` Key string `json:"key"`
Value string `json:"value"` Value string `json:"value"`

View File

@ -110,7 +110,6 @@ type AnalyticsTeamSection struct {
type AnalyticsDashboard struct { type AnalyticsDashboard struct {
GeneratedAt time.Time `json:"generated_at"` GeneratedAt time.Time `json:"generated_at"`
DateFilter AnalyticsDateFilter `json:"date_filter"`
Users AnalyticsUsersSection `json:"users"` Users AnalyticsUsersSection `json:"users"`
Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"` Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"`
Payments AnalyticsPaymentsSection `json:"payments"` 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 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 return err
} }
@ -62,43 +62,21 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
if err != nil { if err != nil {
return err return err
} }
var ( if !scope.ModuleID.Valid {
moduleID *int64 return fmt.Errorf("practice %d is not linked to a module", questionSetID)
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)
} }
crs, err := q.GetCourseByID(ctx, courseID) mod, err := q.GetModuleByID(ctx, scope.ModuleID.Int64)
if err != nil { if err != nil {
return err 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 return err
} }
@ -108,40 +86,38 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
return nil return nil
} }
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID int64, moduleID *int64, courseID, programID int64) error { func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID, moduleID, courseID, programID int64) error {
if moduleID != nil { moduleLessonsTotal, err := q.CountLessonsInModule(ctx, moduleID)
moduleLessonsTotal, err := q.CountLessonsInModule(ctx, *moduleID) if err != nil {
if err != nil { return err
return err }
} moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{ ModuleID: moduleID,
ModuleID: *moduleID, UserID: userID,
UserID: userID, })
}) if err != nil {
if err != nil { return err
return err }
} modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID)) if err != nil {
if err != nil { return err
return err }
} modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{ ModuleID: toPgInt8(&moduleID),
ModuleID: toPgInt8(moduleID), UserID: userID,
UserID: userID, })
}) if err != nil {
if err != nil { return err
return err }
}
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal modulePracticesComplete := modulePracticesDone >= modulePracticesTotal
if !moduleLessonsComplete || !modulePracticesComplete { if !moduleLessonsComplete || !modulePracticesComplete {
return nil return nil
} }
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: *moduleID}); err != nil { if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: moduleID}); err != nil {
return err return err
}
} }
nMods, err := q.CountModulesInCourse(ctx, courseID) nMods, err := q.CountModulesInCourse(ctx, courseID)

View File

@ -15,155 +15,141 @@ func toTime(v interface{}) time.Time {
return 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 { 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() ctx := c.Context()
p := newAnalyticsQueryParams(filter)
usersSummary, err := h.analyticsDB.AnalyticsUsersSummary(ctx, p.UsersSummary) // ── Users ──
usersSummary, err := h.analyticsDB.AnalyticsUsersSummary(ctx)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user analytics") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by role") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by status") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by age group") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by knowledge level") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by region") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user registrations") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch subscription analytics") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch subscriptions by status") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue by plan") 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 { 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payment analytics") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payments by status") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payments by method") 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 { 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) courseCounts, err := h.analyticsDB.AnalyticsCourseCounts(ctx)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch course analytics") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch content analytics") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch questions by type") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch question sets by type") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notification analytics") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications by channel") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications by type") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issue analytics") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issues by status") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issues by type") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team analytics") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by role") 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by status") return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by status")
} }
// ── Map to domain types ──
dashboard := domain.AnalyticsDashboard{ dashboard := domain.AnalyticsDashboard{
GeneratedAt: time.Now().UTC(), GeneratedAt: time.Now(),
DateFilter: filter, Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs),
Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs),
Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30), Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30),
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30), Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30),
Courses: domain.AnalyticsCoursesSection{ 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. set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), id)
practice, practiceErr := h.practiceSvc.GetByID(c.Context(), id) if err != nil {
var set domain.QuestionSet // Backward/UX compatibility: accept either question_set.id or lms_practices.id.
var setErr error practice, practiceErr := h.practiceSvc.GetByID(c.Context(), id)
if practiceErr == nil { if practiceErr != nil {
set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID) return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
} else { Message: "Practice not found",
// 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",
Error: err.Error(), 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 { if err := h.lmsProgressSvc.CompletePracticeForUser(c.Context(), userID, set.ID); err != nil {