Compare commits

...

16 Commits

Author SHA1 Message Date
79851d31b3 email invitation 2026-05-22 05:17:19 -07:00
31bd1e3814 Add team member email invitations for admin panel onboarding
Introduces invite, verify, accept, resend, and revoke flows using team_members and invitation tokens, sends the branded invitation template, and requires account activation before team login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 03:43:00 -07:00
868e5ba001 Apply Yimaru Academy branding to email template seeds
Adds branded HTML layout matching the admin portal purple palette, updates 000066 seeds, and adds 000067 migration to refresh existing template rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 02:12:45 -07:00
5937c5505a Add admin-managed email templates and use them for OTP delivery
Adds CRUD and preview APIs, RBAC permissions, seeded system templates, and migrates OTP email/SMS to template rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 01:28:48 -07:00
1f7b38861e Integrate Chapa for learner subscription payments
Add Chapa checkout, verify, webhook, and callback flows so subscriptions activate only after confirmed payment. Route subscription checkout through Chapa while keeping ArifPay for direct payments. Include integration docs and a Postman collection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 03:35:57 -07:00
de8618191c Normalize broken FCM service account JSON (.env PEM newlines).
Repair multiline PEM inside private_key before Firebase init; add unit test; use normalized JSON for credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 08:57:26 -07:00
f7c9eddef5 Improve FCM service account loading and diagnostics.
Support FCM_SERVICE_ACCOUNT_KEY_FILE, clearer JSON parse errors for common .env mistakes, stop logging credential contents.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 08:41:27 -07:00
14d94ec723 Honor optional sort_order when creating exam-prep units.
Expose sort_order on CreateExamPrepUnitInput; insert applies explicit index with sibling shifting (aligned with LMS course create). Updated Swagger and LMS-Personas Postman collection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 07:18:35 -07:00
5399d33af6 Add optional gender to LMS personas.
Migration 000065 adds nullable gender text column; persona API and Postman expose it alongside profile_picture.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:37:21 -07:00
9ff418247f Always include profile_picture in persona JSON responses.
Remove omitempty on LmsPersona.profile_picture so list/get return null when unset. Add LMS-Personas Postman collection matching current API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:25:55 -07:00
6ab077b53d Rename LMS persona image field to profile_picture.
Add migration 000064 renaming avatar_url column; expose profile_picture in API, sqlc, and Swagger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:17:15 -07:00
9631711090 Seed default LMS personas in migration 063.
Insert ids 1-3 catalog rows and sync sequence on up; delete seed ids on down before dropping lms_personas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:11:09 -07:00
873be1b482 Add LMS personas catalog and CRUD API.
Introduce lms_personas table, repoint practice persona_id FKs off users, validate persona refs on LMS and exam-prep practice flows, personas.* RBAC permissions, and OpenAPI docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:06:42 -07:00
71bc09a638 Make practice title optional on create.
POST /practices and exam-prep practice create accept missing or null title; persist as empty string. Refresh OpenAPI and document the behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 04:11:09 -07:00
bd1767d2a6 Add LMS lesson draft and publish visibility.
Migration 000062 adds lessons.publish_status (DRAFT default for new rows; existing rows published). Editors see all lessons; learners see published-only lists and GET by id. Sequential prerequisites and completion counts ignore drafts. Course lesson_count counts published lessons only. Swagger documents publish_status on create/update bodies.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 02:16:42 -07:00
fffdff1031 Honor optional sort_order on lesson create under a module.
Accept sort_order on CreateLessonInput; SQL falls back to max+1. When set, shift sibling lessons and insert at that position (same pattern as module create). Regenerate sqlc and update Swagger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 01:56:15 -07:00
90 changed files with 6249 additions and 251 deletions

View File

@ -14,10 +14,12 @@ import (
"Yimaru-Backend/internal/repository" "Yimaru-Backend/internal/repository"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/chapa"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
coursesservice "Yimaru-Backend/internal/services/courses" coursesservice "Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/emailtemplates"
"Yimaru-Backend/internal/services/examprep" "Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/faqs" "Yimaru-Backend/internal/services/faqs"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
@ -27,6 +29,7 @@ import (
minioservice "Yimaru-Backend/internal/services/minio" minioservice "Yimaru-Backend/internal/services/minio"
moduleservice "Yimaru-Backend/internal/services/modules" moduleservice "Yimaru-Backend/internal/services/modules"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
personasservice "Yimaru-Backend/internal/services/personas"
practicesservice "Yimaru-Backend/internal/services/practices" practicesservice "Yimaru-Backend/internal/services/practices"
programsservice "Yimaru-Backend/internal/services/programs" programsservice "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
@ -105,16 +108,14 @@ func main() {
settingSvc := settings.NewService(settingRepo) settingSvc := settings.NewService(settingRepo)
messengerSvc := messenger.NewService(settingSvc, cfg) messengerSvc := messenger.NewService(settingSvc, cfg)
// statSvc := stats.NewService( emailTemplateSvc := emailtemplates.NewService(repository.NewEmailTemplateStore(store))
// repository.NewCompanyStatStore(store),
// repository.NewBranchStatStore(store),
// )
userSvc := user.NewService( userSvc := user.NewService(
repository.NewTokenStore(store), repository.NewTokenStore(store),
repository.NewUserStore(store), repository.NewUserStore(store),
repository.NewOTPStore(store), repository.NewOTPStore(store),
messengerSvc, messengerSvc,
emailTemplateSvc,
cfg, cfg,
) )
@ -395,6 +396,7 @@ func main() {
// Questions service (unified questions system) // Questions service (unified questions system)
questionsSvc := questions.NewService(store) questionsSvc := questions.NewService(store)
faqSvc := faqs.NewService(repository.NewFAQStore(store)) faqSvc := faqs.NewService(repository.NewFAQStore(store))
personasSvc := personasservice.NewService(store)
examPrepSvc := examprep.NewService(store) examPrepSvc := examprep.NewService(store)
// LMS programs (top-level hierarchy) // LMS programs (top-level hierarchy)
@ -417,7 +419,7 @@ func main() {
// Subscriptions service // Subscriptions service
subscriptionsSvc := subscriptions.NewService(store) subscriptionsSvc := subscriptions.NewService(store)
// ArifPay service with payment and subscription stores // ArifPay service (direct/legacy payment flows)
arifpaySvc := arifpay.NewArifpayService( arifpaySvc := arifpay.NewArifpayService(
cfg, cfg,
&http.Client{Timeout: 30 * time.Second}, &http.Client{Timeout: 30 * time.Second},
@ -425,8 +427,24 @@ func main() {
store, // implements SubscriptionStore store, // implements SubscriptionStore
) )
// Chapa service for subscription checkout payments
chapaSvc := chapa.NewService(
cfg,
&http.Client{Timeout: 30 * time.Second},
store,
store,
store,
)
// Team management service // Team management service
teamSvc := team.NewService(repository.NewTeamStore(store), cfg.RefreshExpiry) teamSvc := team.NewService(
repository.NewTeamStore(store),
cfg.RefreshExpiry,
emailTemplateSvc,
messengerSvc,
cfg.TeamInviteBaseURL,
cfg.TeamInviteExpiry,
)
// santimpayClient := santimpay.NewSantimPayClient(cfg) // santimpayClient := santimpay.NewSantimPayClient(cfg)
@ -456,6 +474,8 @@ func main() {
assessmentSvc, assessmentSvc,
questionsSvc, questionsSvc,
faqSvc, faqSvc,
emailTemplateSvc,
personasSvc,
examPrepSvc, examPrepSvc,
programSvc, programSvc,
courseSvc, courseSvc,
@ -465,6 +485,7 @@ func main() {
practiceSvc, practiceSvc,
subscriptionsSvc, subscriptionsSvc,
arifpaySvc, arifpaySvc,
chapaSvc,
issueReportingSvc, issueReportingSvc,
vimeoSvc, vimeoSvc,
teamSvc, teamSvc,

View File

@ -0,0 +1,3 @@
ALTER TABLE lessons DROP CONSTRAINT IF EXISTS chk_lessons_publish_status;
ALTER TABLE lessons DROP COLUMN IF EXISTS publish_status;

View File

@ -0,0 +1,9 @@
-- Draft vs published visibility for LMS lessons (mirrors lms_practices.publish_status).
ALTER TABLE lessons
ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED'
CONSTRAINT chk_lessons_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED'));
-- New inserts default to draft unless the API sends PUBLISHED; existing rows stay published.
ALTER TABLE lessons
ALTER COLUMN publish_status SET DEFAULT 'DRAFT';

View File

@ -0,0 +1,21 @@
ALTER TABLE exam_prep.lesson_practices DROP CONSTRAINT IF EXISTS lesson_practices_persona_id_fkey;
UPDATE exam_prep.lesson_practices
SET persona_id = NULL;
ALTER TABLE exam_prep.lesson_practices
ADD CONSTRAINT lesson_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES users (id) ON DELETE SET NULL;
ALTER TABLE lms_practices DROP CONSTRAINT IF EXISTS lms_practices_persona_id_fkey;
UPDATE lms_practices
SET persona_id = NULL;
ALTER TABLE lms_practices
ADD CONSTRAINT lms_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES users (id) ON DELETE SET NULL;
-- Remove seeded default personas before dropping the catalog table.
DELETE FROM lms_personas
WHERE id IN (1, 2, 3);
DROP TABLE IF EXISTS lms_personas;

View File

@ -0,0 +1,64 @@
-- Catalog of LMS personas (coach/avatar profiles) referenced by Learn English + exam-prep practices.
CREATE TABLE lms_personas (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
avatar_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_lms_personas_is_active ON lms_personas (is_active)
WHERE is_active;
CREATE INDEX idx_lms_personas_created_at ON lms_personas (created_at DESC);
-- Default catalog personas (stable ids for envs and Postman); add more via API.
INSERT INTO lms_personas (id, name, description, avatar_url, is_active)
VALUES
(
1,
'Friendly Coach',
'Warm, encouraging tutor for everyday conversational practice.',
NULL,
TRUE
),
(
2,
'Exam Coach',
'Structured, exam-style guidance and clear checkpoints.',
NULL,
TRUE
),
(
3,
'Story Narrator',
'Story-led scenarios with character-driven prompts.',
NULL,
TRUE
);
SELECT setval(
pg_get_serial_sequence('lms_personas', 'id'),
(SELECT COALESCE(MAX(id), 1) FROM lms_personas)
);
-- persona_id historically referenced users.id; personas are now catalog rows on lms_personas.
ALTER TABLE lms_practices DROP CONSTRAINT IF EXISTS lms_practices_persona_id_fkey;
UPDATE lms_practices
SET persona_id = NULL
WHERE persona_id IS NOT NULL;
ALTER TABLE lms_practices
ADD CONSTRAINT lms_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES lms_personas (id) ON DELETE SET NULL;
ALTER TABLE exam_prep.lesson_practices DROP CONSTRAINT IF EXISTS lesson_practices_persona_id_fkey;
UPDATE exam_prep.lesson_practices
SET persona_id = NULL
WHERE persona_id IS NOT NULL;
ALTER TABLE exam_prep.lesson_practices
ADD CONSTRAINT lesson_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES lms_personas (id) ON DELETE SET NULL;

View File

@ -0,0 +1,2 @@
ALTER TABLE lms_personas
RENAME COLUMN profile_picture TO avatar_url;

View File

@ -0,0 +1,3 @@
-- Persona profile image URL stored as profile_picture (replaces avatar_url naming).
ALTER TABLE lms_personas
RENAME COLUMN avatar_url TO profile_picture;

View File

@ -0,0 +1,2 @@
ALTER TABLE lms_personas
DROP COLUMN IF EXISTS gender;

View File

@ -0,0 +1,2 @@
ALTER TABLE lms_personas
ADD COLUMN gender TEXT;

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS email_templates;

View File

@ -0,0 +1,186 @@
CREATE TABLE IF NOT EXISTS email_templates (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
subject TEXT NOT NULL,
body_text TEXT NOT NULL,
body_html TEXT NOT NULL,
variables JSONB NOT NULL DEFAULT '[]'::jsonb,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_email_templates_status ON email_templates(status);
CREATE INDEX IF NOT EXISTS idx_email_templates_slug ON email_templates(slug);
INSERT INTO email_templates (slug, name, subject, body_text, body_html, variables, is_system, status)
VALUES
(
'otp',
'One-Time Password',
'Yimaru Academy — Your verification code',
'Yimaru Academy{{if .FirstName}}, {{.FirstName}}{{end}}
Your verification code is {{.OTP}}.
It expires in {{.ExpiresMinutes}} minutes.
Please do not share this code with anyone.',
$otp_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;letter-spacing:0.3px;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;line-height:1.3;">Your verification code</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}use the code below to continue signing in to Yimaru Academy.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td style="background-color:#eef4ff;border-radius:8px;padding:20px;border:1px solid #e0e8f5;text-align:center;">
<p style="margin:0 0 6px;color:#9d2a83;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;">One-time password</p>
<p style="margin:0;color:#333333;font-size:34px;font-weight:700;letter-spacing:8px;font-family:Consolas,Monaco,monospace;">{{.OTP}}</p>
<p style="margin:12px 0 0;color:#666666;font-size:13px;">Expires in {{.ExpiresMinutes}} minutes</p>
</td></tr></table>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">If you did not request this code, you can safely ignore this email.</p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;line-height:1.5;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$otp_html$,
'["OTP", "FirstName", "ExpiresMinutes"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'invitation',
'User Invitation',
'You are invited to Yimaru Academy',
'Hi{{if .FirstName}} {{.FirstName}}{{end}},
You have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Academy.
Accept your invitation: {{.InviteLink}}',
$invite_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">You&rsquo;re invited</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}you have been invited{{if .InviterName}} by <strong style="color:#9d2a83;">{{.InviterName}}</strong>{{end}} to join Yimaru Academy.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.InviteLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Accept invitation</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">Or copy this link: <a href="{{.InviteLink}}" style="color:#9d2a83;">{{.InviteLink}}</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$invite_html$,
'["FirstName", "InviterName", "InviteLink"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'password_reset',
'Password Reset',
'Reset your Yimaru Academy password',
'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Reset your password: {{.ResetLink}}
This link expires in {{.ExpiresMinutes}} minutes.',
$reset_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Reset your password</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}we received a request to reset your Yimaru Academy password. The link below expires in {{.ExpiresMinutes}} minutes.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.ResetLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Reset password</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">If you did not request a reset, ignore this email.</p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$reset_html$,
'["FirstName", "ResetLink", "ExpiresMinutes"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'welcome',
'Welcome Email',
'Welcome to Yimaru Academy',
'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Welcome to Yimaru Academy! Sign in to get started: {{.LoginURL}}',
$welcome_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Welcome aboard</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}your Yimaru Academy account is ready. Start learning at your own pace.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.LoginURL}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Sign in to Yimaru Academy</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$welcome_html$,
'["FirstName", "LoginURL"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'custom_message',
'Custom Message',
'{{.Subject}}',
'{{.Message}}',
$custom_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">{{.Subject}}</h1>
<div style="margin:0;color:#666666;font-size:15px;line-height:1.6;">{{.Message}}</div>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$custom_html$,
'["Subject", "Message"]'::jsonb,
TRUE,
'ACTIVE'
)
ON CONFLICT (slug) DO NOTHING;

View File

@ -0,0 +1 @@
-- No-op: branded template content is not reverted automatically.

View File

@ -0,0 +1,156 @@
-- Refresh system email templates with Yimaru Academy branded HTML (admin portal colors).
-- Safe to run after 000066 when seeds used the original plain layout.
UPDATE email_templates SET
name = 'One-Time Password',
subject = 'Yimaru Academy — Your verification code',
body_text = 'Yimaru Academy{{if .FirstName}}, {{.FirstName}}{{end}}
Your verification code is {{.OTP}}.
It expires in {{.ExpiresMinutes}} minutes.
Please do not share this code with anyone.',
body_html = $otp_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;letter-spacing:0.3px;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;line-height:1.3;">Your verification code</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}use the code below to continue signing in to Yimaru Academy.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td style="background-color:#eef4ff;border-radius:8px;padding:20px;border:1px solid #e0e8f5;text-align:center;">
<p style="margin:0 0 6px;color:#9d2a83;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;">One-time password</p>
<p style="margin:0;color:#333333;font-size:34px;font-weight:700;letter-spacing:8px;font-family:Consolas,Monaco,monospace;">{{.OTP}}</p>
<p style="margin:12px 0 0;color:#666666;font-size:13px;">Expires in {{.ExpiresMinutes}} minutes</p>
</td></tr></table>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">If you did not request this code, you can safely ignore this email.</p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;line-height:1.5;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$otp_html$,
updated_at = NOW()
WHERE slug = 'otp';
UPDATE email_templates SET
name = 'User Invitation',
subject = 'You are invited to Yimaru Academy',
body_text = 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
You have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Academy.
Accept your invitation: {{.InviteLink}}',
body_html = $invite_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">You&rsquo;re invited</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}you have been invited{{if .InviterName}} by <strong style="color:#9d2a83;">{{.InviterName}}</strong>{{end}} to join Yimaru Academy.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.InviteLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Accept invitation</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">Or copy this link: <a href="{{.InviteLink}}" style="color:#9d2a83;">{{.InviteLink}}</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$invite_html$,
updated_at = NOW()
WHERE slug = 'invitation';
UPDATE email_templates SET
name = 'Password Reset',
subject = 'Reset your Yimaru Academy password',
body_text = 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Reset your password: {{.ResetLink}}
This link expires in {{.ExpiresMinutes}} minutes.',
body_html = $reset_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Reset your password</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}we received a request to reset your Yimaru Academy password. The link below expires in {{.ExpiresMinutes}} minutes.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.ResetLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Reset password</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">If you did not request a reset, ignore this email.</p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$reset_html$,
updated_at = NOW()
WHERE slug = 'password_reset';
UPDATE email_templates SET
name = 'Welcome Email',
subject = 'Welcome to Yimaru Academy',
body_text = 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Welcome to Yimaru Academy! Sign in to get started: {{.LoginURL}}',
body_html = $welcome_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Welcome aboard</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}your Yimaru Academy account is ready. Start learning at your own pace.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.LoginURL}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Sign in to Yimaru Academy</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$welcome_html$,
updated_at = NOW()
WHERE slug = 'welcome';
UPDATE email_templates SET
name = 'Custom Message',
body_html = $custom_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">{{.Subject}}</h1>
<div style="margin:0;color:#666666;font-size:15px;line-height:1.6;">{{.Message}}</div>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$custom_html$,
updated_at = NOW()
WHERE slug = 'custom_message';

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS team_invitations;

View File

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS team_invitations (
id BIGSERIAL PRIMARY KEY,
team_member_id BIGINT NOT NULL REFERENCES team_members(id) ON DELETE CASCADE,
token VARCHAR(128) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (
status IN ('pending', 'accepted', 'expired', 'revoked')
),
expires_at TIMESTAMPTZ NOT NULL,
invited_by BIGINT,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_team_invitations_token ON team_invitations(token);
CREATE INDEX IF NOT EXISTS idx_team_invitations_team_member_id ON team_invitations(team_member_id);
CREATE INDEX IF NOT EXISTS idx_team_invitations_status ON team_invitations(status);
CREATE INDEX IF NOT EXISTS idx_team_invitations_expires_at ON team_invitations(expires_at);

View File

@ -1,16 +1,17 @@
-- name: ExamPrepCreateUnit :one -- name: ExamPrepCreateUnit :one
INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order) INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order)
SELECT SELECT
$1, sqlc.arg('catalog_course_id'),
$2, sqlc.arg('name'),
$3, sqlc.arg('description'),
$4, sqlc.arg('thumbnail'),
coalesce(( COALESCE(sqlc.narg('sort_order')::int,
SELECT COALESCE((
max(u.sort_order) SELECT
FROM exam_prep.units u max(u.sort_order)
WHERE FROM exam_prep.units u
u.catalog_course_id = $1), 0) + 1 WHERE
u.catalog_course_id = sqlc.arg('catalog_course_id')), 0) + 1)
RETURNING RETURNING
*; *;

View File

@ -65,7 +65,8 @@ SELECT
lessons l lessons l
INNER JOIN modules m ON l.module_id = m.id INNER JOIN modules m ON l.module_id = m.id
WHERE WHERE
m.course_id = c.id) AS lesson_count, m.course_id = c.id
AND l.publish_status = 'PUBLISHED') AS lesson_count,
-- Practices whose parent is the course only (lms_practices.course_id). Excludes -- Practices whose parent is the course only (lms_practices.course_id). Excludes
-- practices linked via module_id or lesson_id, even for modules/lessons in this course. -- practices linked via module_id or lesson_id, even for modules/lessons in this course.
( (

View File

@ -1,17 +1,19 @@
-- name: CreateLesson :one -- name: CreateLesson :one
INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order) INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order, publish_status)
SELECT SELECT
$1, sqlc.arg('module_id'),
$2, sqlc.arg('title'),
$3, sqlc.arg('video_url'),
$4, sqlc.arg('thumbnail'),
$5, sqlc.arg('description'),
coalesce(( COALESCE(sqlc.narg('sort_order')::int,
SELECT COALESCE((
max(l.sort_order) SELECT
FROM lessons l max(l.sort_order)
WHERE FROM lessons l
l.module_id = $1), 0) + 1 WHERE
l.module_id = sqlc.arg('module_id')), 0) + 1),
sqlc.arg('publish_status')
RETURNING RETURNING
*; *;
@ -38,6 +40,7 @@ SELECT
l.thumbnail, l.thumbnail,
l.description, l.description,
l.sort_order, l.sort_order,
l.publish_status,
l.created_at, l.created_at,
l.updated_at, l.updated_at,
EXISTS ( EXISTS (
@ -50,6 +53,10 @@ FROM
lessons l lessons l
WHERE WHERE
l.module_id = $1 l.module_id = $1
AND (
sqlc.arg('published_only')::boolean = FALSE
OR l.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY ORDER BY
l.sort_order ASC, l.sort_order ASC,
l.id ASC l.id ASC
@ -64,6 +71,7 @@ SET
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
description = COALESCE(sqlc.narg('description')::text, description), description = COALESCE(sqlc.narg('description')::text, description),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
publish_status = COALESCE(sqlc.narg('publish_status')::varchar, publish_status),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE WHERE
id = sqlc.arg('id') id = sqlc.arg('id')

44
db/query/lms_personas.sql Normal file
View File

@ -0,0 +1,44 @@
-- name: CreateLmsPersona :one
INSERT INTO lms_personas (name, description, profile_picture, gender, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: GetLmsPersonaByID :one
SELECT *
FROM lms_personas
WHERE id = $1;
-- name: UpdateLmsPersona :one
UPDATE lms_personas
SET
name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description),
profile_picture = COALESCE(sqlc.narg('profile_picture')::text, profile_picture),
gender = COALESCE(sqlc.narg('gender')::text, gender),
is_active = COALESCE(sqlc.narg('is_active')::boolean, is_active),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
RETURNING *;
-- name: DeleteLmsPersona :exec
DELETE FROM lms_personas
WHERE id = $1;
-- name: ListLmsPersonas :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.name,
p.description,
p.profile_picture,
p.gender,
p.is_active,
p.created_at,
p.updated_at
FROM lms_personas p
WHERE (
sqlc.arg('filter_active')::boolean = FALSE
OR p.is_active = TRUE
)
ORDER BY p.name ASC, p.created_at DESC
LIMIT $1 OFFSET $2;

View File

@ -33,9 +33,21 @@ SELECT
FROM FROM
lessons AS l1 lessons AS l1
INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id
AND l2.sort_order = l1.sort_order - 1 AND l2.publish_status = 'PUBLISHED'
AND l1.publish_status = 'PUBLISHED'
AND (
l2.sort_order < l1.sort_order
OR (
l2.sort_order = l1.sort_order
AND l2.id < l1.id
)
)
WHERE WHERE
l1.id = $1; l1.id = $1
ORDER BY
l2.sort_order DESC,
l2.id DESC
LIMIT 1;
-- name: UserHasProgramProgress :one -- name: UserHasProgramProgress :one
SELECT SELECT
@ -111,7 +123,8 @@ SELECT
FROM FROM
lessons lessons
WHERE WHERE
module_id = $1; module_id = $1
AND publish_status = 'PUBLISHED';
-- name: CountUserCompletedLessonsInModule :one -- name: CountUserCompletedLessonsInModule :one
SELECT SELECT
@ -121,7 +134,8 @@ FROM
INNER JOIN lessons l ON l.id = ulp.lesson_id INNER JOIN lessons l ON l.id = ulp.lesson_id
WHERE WHERE
l.module_id = $1 l.module_id = $1
AND ulp.user_id = $2; AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED';
-- name: CountModulesInCourse :one -- name: CountModulesInCourse :one
SELECT SELECT
@ -211,7 +225,8 @@ FROM
lessons l lessons l
INNER JOIN modules m ON m.id = l.module_id INNER JOIN modules m ON m.id = l.module_id
WHERE WHERE
m.course_id = $1; m.course_id = $1
AND l.publish_status = 'PUBLISHED';
-- name: CountUserCompletedLessonsInCourse :one -- name: CountUserCompletedLessonsInCourse :one
SELECT SELECT
@ -222,7 +237,8 @@ FROM
INNER JOIN modules m ON m.id = l.module_id INNER JOIN modules m ON m.id = l.module_id
WHERE WHERE
m.course_id = $1 m.course_id = $1
AND ulp.user_id = $2; AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED';
-- Lesson-based progress within a program (all courses). -- Lesson-based progress within a program (all courses).
-- name: CountLessonsInProgram :one -- name: CountLessonsInProgram :one
@ -233,7 +249,8 @@ FROM
INNER JOIN modules m ON m.id = l.module_id INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id INNER JOIN courses c ON c.id = m.course_id
WHERE WHERE
c.program_id = $1; c.program_id = $1
AND l.publish_status = 'PUBLISHED';
-- name: CountUserCompletedLessonsInProgram :one -- name: CountUserCompletedLessonsInProgram :one
SELECT SELECT
@ -245,7 +262,8 @@ FROM
INNER JOIN courses c ON c.id = m.course_id INNER JOIN courses c ON c.id = m.course_id
WHERE WHERE
c.program_id = $1 c.program_id = $1
AND ulp.user_id = $2; AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED';
-- Published practices in a module (module-level and lesson-level practices should carry module_id). -- Published practices in a module (module-level and lesson-level practices should carry module_id).
-- name: CountPublishedPracticesInModule :one -- name: CountPublishedPracticesInModule :one

View File

@ -0,0 +1,80 @@
-- name: CreateTeamInvitation :one
INSERT INTO team_invitations (
team_member_id,
token,
status,
expires_at,
invited_by,
updated_at
)
VALUES ($1, $2, 'pending', $3, $4, CURRENT_TIMESTAMP)
RETURNING *;
-- name: GetTeamInvitationByToken :one
SELECT * FROM team_invitations
WHERE token = $1;
-- name: GetTeamInvitationByID :one
SELECT * FROM team_invitations
WHERE id = $1;
-- name: GetPendingTeamInvitationByMemberID :one
SELECT * FROM team_invitations
WHERE team_member_id = $1
AND status = 'pending'
ORDER BY created_at DESC
LIMIT 1;
-- name: RevokePendingTeamInvitationsForMember :exec
UPDATE team_invitations
SET status = 'revoked',
updated_at = CURRENT_TIMESTAMP
WHERE team_member_id = $1
AND status = 'pending';
-- name: AcceptTeamInvitation :one
UPDATE team_invitations
SET status = 'accepted',
accepted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND status = 'pending'
RETURNING *;
-- name: RevokeTeamInvitation :one
UPDATE team_invitations
SET status = 'revoked',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND status = 'pending'
RETURNING *;
-- name: ExpireTeamInvitation :exec
UPDATE team_invitations
SET status = 'expired',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND status = 'pending';
-- name: ListTeamInvitations :many
SELECT
ti.id,
ti.team_member_id,
ti.token,
ti.status,
ti.expires_at,
ti.invited_by,
ti.accepted_at,
ti.created_at,
ti.updated_at,
tm.email,
tm.first_name,
tm.last_name,
tm.team_role,
COUNT(*) OVER () AS total_count
FROM team_invitations ti
INNER JOIN team_members tm ON tm.id = ti.team_member_id
WHERE (sqlc.narg('status')::text IS NULL OR ti.status = sqlc.narg('status')::text)
ORDER BY ti.created_at DESC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;

83
docs/CHAPA_INTEGRATION.md Normal file
View File

@ -0,0 +1,83 @@
# Chapa Payment Gateway Integration
Subscription payments for learners use [Chapa](https://developer.chapa.co/docs) hosted checkout, following the same payment-first flow as the previous ArifPay integration.
## Overview
- Subscriptions are created only after Chapa confirms payment (webhook and/or verify).
- `tx_ref` is stored as the payment `nonce` and returned as `session_id` in API responses.
- ArifPay direct-payment routes remain available for legacy flows; subscription checkout uses Chapa.
## Environment Variables
```env
CHAPA_SECRET_KEY=CHASECK_TEST-xxxxxxxx
CHAPA_PUBLIC_KEY=CHAPUBK_TEST-xxxxxxxx
CHAPA_WEBHOOK_SECRET=your_webhook_secret_from_dashboard
CHAPA_BASE_URL=https://api.chapa.co/v1
CHAPA_CALLBACK_URL=https://your-api.example.com/api/v1/payments/chapa/callback
CHAPA_RETURN_URL=https://your-app.example.com/payment/success
CHAPA_RECEIPT_URL=
```
Configure the same webhook URL in the Chapa dashboard:
`https://your-api.example.com/api/v1/payments/webhook`
## Payment Flow
1. Learner calls `POST /api/v1/subscriptions/checkout` or `POST /api/v1/payments/subscribe`.
2. Backend creates a pending payment and calls Chapa `POST /transaction/initialize`.
3. Client redirects the user to `payment_url` (`checkout_url` from Chapa).
4. After payment, Chapa calls `callback_url` and sends a webhook.
5. Backend verifies via `GET /transaction/verify/{tx_ref}` and activates the subscription.
6. Client may poll `GET /api/v1/payments/verify/{tx_ref}` (`session_id` path param is the `tx_ref`).
## API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/v1/subscriptions/checkout` | Yes | Initiate subscription payment |
| POST | `/api/v1/payments/subscribe` | Yes | Same as checkout |
| GET | `/api/v1/payments/verify/:session_id` | Yes | Verify by `tx_ref` |
| POST | `/api/v1/payments/webhook` | No | Chapa webhook (HMAC signature required) |
| GET | `/api/v1/payments/chapa/callback` | No | Chapa redirect callback |
| GET | `/api/v1/payments/methods` | No | Supported Chapa methods |
### Initiate payment request
```json
{
"plan_id": 1,
"phone": "0912345678",
"email": "learner@example.com"
}
```
### Initiate payment response
```json
{
"message": "Payment initiated. Complete payment to activate subscription.",
"data": {
"payment_id": 42,
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"payment_url": "https://checkout.chapa.co/checkout/payment/...",
"amount": 500,
"currency": "ETB",
"expires_at": "2026-05-21T18:00:00Z"
}
}
```
## Webhook Security
Chapa signs the raw JSON body with HMAC-SHA256 using your webhook secret. The handler checks `x-chapa-signature` or `chapa-signature` before processing.
## Testing
Use Chapa test keys and [test credentials](https://developer.chapa.co/test/testing-mobile). After checkout, confirm the subscription via verify endpoint or webhook logs.
### Postman
Import `postman/Chapa-Subscription-Payments.postman_collection.json`. Set collection variables (`base_url`, learner credentials, `chapa_webhook_secret`), then run folders **00 → 02** in order.

View File

@ -415,6 +415,8 @@ This creates the practice record scoped to lesson.
### Request ### Request
`title` is optional; omit it or use an empty string to create a practice without a display title (stored as empty).
Include `publish_status`: `DRAFT` to hide the practice from subscribed learners until you set it to `PUBLISHED` (via create or `PUT /practices/:id`). Omit the field or send `PUBLISHED` to go live immediately (backward compatible). Include `publish_status`: `DRAFT` to hide the practice from subscribed learners until you set it to `PUBLISHED` (via create or `PUT /practices/:id`). Omit the field or send `PUBLISHED` to go live immediately (backward compatible).
```json ```json

View File

@ -1653,7 +1653,7 @@ const docTemplate = `{
} }
}, },
"post": { "post": {
"description": "Unit under a catalog course (e.g. chapter title)", "description": "Unit under a catalog course (e.g. chapter title). Optional sort_order assigns position within that catalog course (siblings at or after that index are shifted); omit to append after the current highest sort_order in the catalog course.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -3963,6 +3963,123 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/personas": {
"get": {
"tags": [
"personas"
],
"summary": "List LMS personas (catalog for practice assignment)",
"parameters": [
{
"type": "boolean",
"default": true,
"description": "When true (default), return only active personas",
"name": "active_only",
"in": "query"
},
{
"type": "integer",
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {}
},
"post": {
"consumes": [
"application/json"
],
"tags": [
"personas"
],
"summary": "Create LMS persona catalog entry",
"parameters": [
{
"description": "Persona",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateLmsPersonaInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/personas/{id}": {
"get": {
"tags": [
"personas"
],
"summary": "Get LMS persona by ID",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
},
"put": {
"tags": [
"personas"
],
"summary": "Update LMS persona",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateLmsPersonaInput"
}
}
],
"responses": {}
},
"delete": {
"tags": [
"personas"
],
"summary": "Delete LMS persona (practices referencing it will have persona_id cleared)",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
}
},
"/api/v1/practices": { "/api/v1/practices": {
"post": { "post": {
"consumes": [ "consumes": [
@ -10434,13 +10551,21 @@ const docTemplate = `{
"domain.CreateExamPrepPracticeInput": { "domain.CreateExamPrepPracticeInput": {
"type": "object", "type": "object",
"required": [ "required": [
"question_set_id", "question_set_id"
"title"
], ],
"properties": { "properties": {
"persona_id": { "persona_id": {
"type": "integer" "type": "integer"
}, },
"publish_status": {
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"question_set_id": { "question_set_id": {
"type": "integer" "type": "integer"
}, },
@ -10470,6 +10595,11 @@ const docTemplate = `{
"name": { "name": {
"type": "string" "type": "string"
}, },
"sort_order": {
"description": "SortOrder within the catalog course when set; omit to append after current max sort_order within catalog_course_id.",
"type": "integer",
"minimum": 0
},
"thumbnail": { "thumbnail": {
"type": "string" "type": "string"
} }
@ -10484,6 +10614,21 @@ const docTemplate = `{
"description": { "description": {
"type": "string" "type": "string"
}, },
"publish_status": {
"description": "Omit or empty defaults to DRAFT; set PUBLISHED to make visible to learners immediately.",
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"sort_order": {
"description": "SortOrder within the module when set; omit to append after current max within module_id.",
"type": "integer",
"minimum": 0
},
"thumbnail": { "thumbnail": {
"type": "string" "type": "string"
}, },
@ -10495,6 +10640,29 @@ const docTemplate = `{
} }
} }
}, },
"domain.CreateLmsPersonaInput": {
"type": "object",
"required": [
"name"
],
"properties": {
"description": {
"type": "string"
},
"gender": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
},
"profile_picture": {
"type": "string"
}
}
},
"domain.CreateModuleInput": { "domain.CreateModuleInput": {
"type": "object", "type": "object",
"required": [ "required": [
@ -10509,6 +10677,11 @@ const docTemplate = `{
}, },
"name": { "name": {
"type": "string" "type": "string"
},
"sort_order": {
"description": "SortOrder within the course when set; omit to append after current max within course_id (uniqueness is per-course).",
"type": "integer",
"minimum": 0
} }
} }
}, },
@ -10517,8 +10690,7 @@ const docTemplate = `{
"required": [ "required": [
"parent_id", "parent_id",
"parent_kind", "parent_kind",
"question_set_id", "question_set_id"
"title"
], ],
"properties": { "properties": {
"parent_id": { "parent_id": {
@ -10539,6 +10711,16 @@ const docTemplate = `{
"persona_id": { "persona_id": {
"type": "integer" "type": "integer"
}, },
"publish_status": {
"description": "Omit or empty for backward compatibility defaults to PUBLISHED; set DRAFT to save hidden from learners until published.",
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"question_set_id": { "question_set_id": {
"type": "integer" "type": "integer"
}, },
@ -11320,6 +11502,15 @@ const docTemplate = `{
"persona_id": { "persona_id": {
"type": "integer" "type": "integer"
}, },
"publish_status": {
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"question_set_id": { "question_set_id": {
"type": "integer" "type": "integer"
}, },
@ -11372,6 +11563,15 @@ const docTemplate = `{
"description": { "description": {
"type": "string" "type": "string"
}, },
"publish_status": {
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"sort_order": { "sort_order": {
"type": "integer" "type": "integer"
}, },
@ -11386,6 +11586,26 @@ const docTemplate = `{
} }
} }
}, },
"domain.UpdateLmsPersonaInput": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"gender": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
},
"profile_picture": {
"type": "string"
}
}
},
"domain.UpdateModuleInput": { "domain.UpdateModuleInput": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -11409,6 +11629,15 @@ const docTemplate = `{
"persona_id": { "persona_id": {
"type": "integer" "type": "integer"
}, },
"publish_status": {
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"question_set_id": { "question_set_id": {
"type": "integer" "type": "integer"
}, },

View File

@ -1645,7 +1645,7 @@
} }
}, },
"post": { "post": {
"description": "Unit under a catalog course (e.g. chapter title)", "description": "Unit under a catalog course (e.g. chapter title). Optional sort_order assigns position within that catalog course (siblings at or after that index are shifted); omit to append after the current highest sort_order in the catalog course.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -3955,6 +3955,123 @@
} }
} }
}, },
"/api/v1/personas": {
"get": {
"tags": [
"personas"
],
"summary": "List LMS personas (catalog for practice assignment)",
"parameters": [
{
"type": "boolean",
"default": true,
"description": "When true (default), return only active personas",
"name": "active_only",
"in": "query"
},
{
"type": "integer",
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {}
},
"post": {
"consumes": [
"application/json"
],
"tags": [
"personas"
],
"summary": "Create LMS persona catalog entry",
"parameters": [
{
"description": "Persona",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateLmsPersonaInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/personas/{id}": {
"get": {
"tags": [
"personas"
],
"summary": "Get LMS persona by ID",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
},
"put": {
"tags": [
"personas"
],
"summary": "Update LMS persona",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateLmsPersonaInput"
}
}
],
"responses": {}
},
"delete": {
"tags": [
"personas"
],
"summary": "Delete LMS persona (practices referencing it will have persona_id cleared)",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
}
},
"/api/v1/practices": { "/api/v1/practices": {
"post": { "post": {
"consumes": [ "consumes": [
@ -10426,13 +10543,21 @@
"domain.CreateExamPrepPracticeInput": { "domain.CreateExamPrepPracticeInput": {
"type": "object", "type": "object",
"required": [ "required": [
"question_set_id", "question_set_id"
"title"
], ],
"properties": { "properties": {
"persona_id": { "persona_id": {
"type": "integer" "type": "integer"
}, },
"publish_status": {
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"question_set_id": { "question_set_id": {
"type": "integer" "type": "integer"
}, },
@ -10462,6 +10587,11 @@
"name": { "name": {
"type": "string" "type": "string"
}, },
"sort_order": {
"description": "SortOrder within the catalog course when set; omit to append after current max sort_order within catalog_course_id.",
"type": "integer",
"minimum": 0
},
"thumbnail": { "thumbnail": {
"type": "string" "type": "string"
} }
@ -10476,6 +10606,21 @@
"description": { "description": {
"type": "string" "type": "string"
}, },
"publish_status": {
"description": "Omit or empty defaults to DRAFT; set PUBLISHED to make visible to learners immediately.",
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"sort_order": {
"description": "SortOrder within the module when set; omit to append after current max within module_id.",
"type": "integer",
"minimum": 0
},
"thumbnail": { "thumbnail": {
"type": "string" "type": "string"
}, },
@ -10487,6 +10632,29 @@
} }
} }
}, },
"domain.CreateLmsPersonaInput": {
"type": "object",
"required": [
"name"
],
"properties": {
"description": {
"type": "string"
},
"gender": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
},
"profile_picture": {
"type": "string"
}
}
},
"domain.CreateModuleInput": { "domain.CreateModuleInput": {
"type": "object", "type": "object",
"required": [ "required": [
@ -10501,6 +10669,11 @@
}, },
"name": { "name": {
"type": "string" "type": "string"
},
"sort_order": {
"description": "SortOrder within the course when set; omit to append after current max within course_id (uniqueness is per-course).",
"type": "integer",
"minimum": 0
} }
} }
}, },
@ -10509,8 +10682,7 @@
"required": [ "required": [
"parent_id", "parent_id",
"parent_kind", "parent_kind",
"question_set_id", "question_set_id"
"title"
], ],
"properties": { "properties": {
"parent_id": { "parent_id": {
@ -10531,6 +10703,16 @@
"persona_id": { "persona_id": {
"type": "integer" "type": "integer"
}, },
"publish_status": {
"description": "Omit or empty for backward compatibility defaults to PUBLISHED; set DRAFT to save hidden from learners until published.",
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"question_set_id": { "question_set_id": {
"type": "integer" "type": "integer"
}, },
@ -11312,6 +11494,15 @@
"persona_id": { "persona_id": {
"type": "integer" "type": "integer"
}, },
"publish_status": {
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"question_set_id": { "question_set_id": {
"type": "integer" "type": "integer"
}, },
@ -11364,6 +11555,15 @@
"description": { "description": {
"type": "string" "type": "string"
}, },
"publish_status": {
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"sort_order": { "sort_order": {
"type": "integer" "type": "integer"
}, },
@ -11378,6 +11578,26 @@
} }
} }
}, },
"domain.UpdateLmsPersonaInput": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"gender": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
},
"profile_picture": {
"type": "string"
}
}
},
"domain.UpdateModuleInput": { "domain.UpdateModuleInput": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -11401,6 +11621,15 @@
"persona_id": { "persona_id": {
"type": "integer" "type": "integer"
}, },
"publish_status": {
"type": "string",
"enum": [
"DRAFT",
"draft",
"PUBLISHED",
"published"
]
},
"question_set_id": { "question_set_id": {
"type": "integer" "type": "integer"
}, },

View File

@ -389,6 +389,13 @@ definitions:
properties: properties:
persona_id: persona_id:
type: integer type: integer
publish_status:
enum:
- DRAFT
- draft
- PUBLISHED
- published
type: string
question_set_id: question_set_id:
type: integer type: integer
quick_tips: quick_tips:
@ -401,7 +408,6 @@ definitions:
type: string type: string
required: required:
- question_set_id - question_set_id
- title
type: object type: object
domain.CreateExamPrepUnitInput: domain.CreateExamPrepUnitInput:
properties: properties:
@ -409,6 +415,11 @@ definitions:
type: string type: string
name: name:
type: string type: string
sort_order:
description: SortOrder within the catalog course when set; omit to append
after current max sort_order within catalog_course_id.
minimum: 0
type: integer
thumbnail: thumbnail:
type: string type: string
required: required:
@ -418,6 +429,20 @@ definitions:
properties: properties:
description: description:
type: string type: string
publish_status:
description: Omit or empty defaults to DRAFT; set PUBLISHED to make visible
to learners immediately.
enum:
- DRAFT
- draft
- PUBLISHED
- published
type: string
sort_order:
description: SortOrder within the module when set; omit to append after current
max within module_id.
minimum: 0
type: integer
thumbnail: thumbnail:
type: string type: string
title: title:
@ -427,6 +452,21 @@ definitions:
required: required:
- title - title
type: object type: object
domain.CreateLmsPersonaInput:
properties:
description:
type: string
gender:
type: string
is_active:
type: boolean
name:
type: string
profile_picture:
type: string
required:
- name
type: object
domain.CreateModuleInput: domain.CreateModuleInput:
properties: properties:
description: description:
@ -435,6 +475,11 @@ definitions:
type: string type: string
name: name:
type: string type: string
sort_order:
description: SortOrder within the course when set; omit to append after current
max within course_id (uniqueness is per-course).
minimum: 0
type: integer
required: required:
- name - name
type: object type: object
@ -451,6 +496,15 @@ definitions:
- LESSON - LESSON
persona_id: persona_id:
type: integer type: integer
publish_status:
description: Omit or empty for backward compatibility defaults to PUBLISHED;
set DRAFT to save hidden from learners until published.
enum:
- DRAFT
- draft
- PUBLISHED
- published
type: string
question_set_id: question_set_id:
type: integer type: integer
quick_tips: quick_tips:
@ -465,7 +519,6 @@ definitions:
- parent_id - parent_id
- parent_kind - parent_kind
- question_set_id - question_set_id
- title
type: object type: object
domain.CreateProgramInput: domain.CreateProgramInput:
properties: properties:
@ -991,6 +1044,13 @@ definitions:
properties: properties:
persona_id: persona_id:
type: integer type: integer
publish_status:
enum:
- DRAFT
- draft
- PUBLISHED
- published
type: string
question_set_id: question_set_id:
type: integer type: integer
quick_tips: quick_tips:
@ -1025,6 +1085,13 @@ definitions:
properties: properties:
description: description:
type: string type: string
publish_status:
enum:
- DRAFT
- draft
- PUBLISHED
- published
type: string
sort_order: sort_order:
type: integer type: integer
thumbnail: thumbnail:
@ -1034,6 +1101,19 @@ definitions:
video_url: video_url:
type: string type: string
type: object type: object
domain.UpdateLmsPersonaInput:
properties:
description:
type: string
gender:
type: string
is_active:
type: boolean
name:
type: string
profile_picture:
type: string
type: object
domain.UpdateModuleInput: domain.UpdateModuleInput:
properties: properties:
description: description:
@ -1049,6 +1129,13 @@ definitions:
properties: properties:
persona_id: persona_id:
type: integer type: integer
publish_status:
enum:
- DRAFT
- draft
- PUBLISHED
- published
type: string
question_set_id: question_set_id:
type: integer type: integer
quick_tips: quick_tips:
@ -3581,7 +3668,10 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: Unit under a catalog course (e.g. chapter title) description: Unit under a catalog course (e.g. chapter title). Optional sort_order
assigns position within that catalog course (siblings at or after that index
are shifted); omit to append after the current highest sort_order in the catalog
course.
parameters: parameters:
- description: Catalog course ID - description: Catalog course ID
in: path in: path
@ -5114,6 +5204,84 @@ paths:
summary: Handle ArifPay webhook summary: Handle ArifPay webhook
tags: tags:
- payments - payments
/api/v1/personas:
get:
parameters:
- default: true
description: When true (default), return only active personas
in: query
name: active_only
type: boolean
- description: Page size
in: query
name: limit
type: integer
- description: Offset
in: query
name: offset
type: integer
responses: {}
summary: List LMS personas (catalog for practice assignment)
tags:
- personas
post:
consumes:
- application/json
parameters:
- description: Persona
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.CreateLmsPersonaInput'
responses:
"201":
description: Created
schema:
$ref: '#/definitions/domain.Response'
summary: Create LMS persona catalog entry
tags:
- personas
/api/v1/personas/{id}:
delete:
parameters:
- description: Persona ID
in: path
name: id
required: true
type: integer
responses: {}
summary: Delete LMS persona (practices referencing it will have persona_id cleared)
tags:
- personas
get:
parameters:
- description: Persona ID
in: path
name: id
required: true
type: integer
responses: {}
summary: Get LMS persona by ID
tags:
- personas
put:
parameters:
- description: Persona ID
in: path
name: id
required: true
type: integer
- description: Fields to update
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.UpdateLmsPersonaInput'
responses: {}
summary: Update LMS persona
tags:
- personas
/api/v1/practices: /api/v1/practices:
post: post:
consumes: consumes:

View File

@ -18,12 +18,13 @@ SELECT
$2, $2,
$3, $3,
$4, $4,
coalesce(( COALESCE($5::int,
SELECT COALESCE((
max(u.sort_order) SELECT
FROM exam_prep.units u max(u.sort_order)
WHERE FROM exam_prep.units u
u.catalog_course_id = $1), 0) + 1 WHERE
u.catalog_course_id = $1), 0) + 1)
RETURNING RETURNING
id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at
` `
@ -33,6 +34,7 @@ type ExamPrepCreateUnitParams struct {
Name string `json:"name"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"`
} }
func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnitParams) (ExamPrepUnit, error) { func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnitParams) (ExamPrepUnit, error) {
@ -41,6 +43,7 @@ func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnit
arg.Name, arg.Name,
arg.Description, arg.Description,
arg.Thumbnail, arg.Thumbnail,
arg.SortOrder,
) )
var i ExamPrepUnit var i ExamPrepUnit
err := row.Scan( err := row.Scan(

View File

@ -170,7 +170,8 @@ SELECT
lessons l lessons l
INNER JOIN modules m ON l.module_id = m.id INNER JOIN modules m ON l.module_id = m.id
WHERE WHERE
m.course_id = c.id) AS lesson_count, m.course_id = c.id
AND l.publish_status = 'PUBLISHED') AS lesson_count,
-- Practices whose parent is the course only (lms_practices.course_id). Excludes -- Practices whose parent is the course only (lms_practices.course_id). Excludes
-- practices linked via module_id or lesson_id, even for modules/lessons in this course. -- practices linked via module_id or lesson_id, even for modules/lessons in this course.
( (

View File

@ -12,29 +12,33 @@ import (
) )
const CreateLesson = `-- name: CreateLesson :one const CreateLesson = `-- name: CreateLesson :one
INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order) INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order, publish_status)
SELECT SELECT
$1, $1,
$2, $2,
$3, $3,
$4, $4,
$5, $5,
coalesce(( COALESCE($6::int,
SELECT COALESCE((
max(l.sort_order) SELECT
FROM lessons l max(l.sort_order)
WHERE FROM lessons l
l.module_id = $1), 0) + 1 WHERE
l.module_id = $1), 0) + 1),
$7
RETURNING RETURNING
id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order, publish_status
` `
type CreateLessonParams struct { type CreateLessonParams struct {
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Title string `json:"title"` Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
SortOrder pgtype.Int4 `json:"sort_order"`
PublishStatus string `json:"publish_status"`
} }
func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Lesson, error) { func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Lesson, error) {
@ -44,6 +48,8 @@ func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Les
arg.VideoUrl, arg.VideoUrl,
arg.Thumbnail, arg.Thumbnail,
arg.Description, arg.Description,
arg.SortOrder,
arg.PublishStatus,
) )
var i Lesson var i Lesson
err := row.Scan( err := row.Scan(
@ -56,6 +62,7 @@ func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Les
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.PublishStatus,
) )
return i, err return i, err
} }
@ -72,7 +79,7 @@ func (q *Queries) DeleteLesson(ctx context.Context, id int64) error {
const GetLessonByID = `-- name: GetLessonByID :one const GetLessonByID = `-- name: GetLessonByID :one
SELECT SELECT
l.id, l.module_id, l.title, l.video_url, l.thumbnail, l.description, l.created_at, l.updated_at, l.sort_order, l.id, l.module_id, l.title, l.video_url, l.thumbnail, l.description, l.created_at, l.updated_at, l.sort_order, l.publish_status,
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM lms_practices p FROM lms_practices p
@ -85,16 +92,17 @@ WHERE l.id = $1
` `
type GetLessonByIDRow struct { type GetLessonByIDRow struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Title string `json:"title"` Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
HasPractice bool `json:"has_practice"` PublishStatus string `json:"publish_status"`
HasPractice bool `json:"has_practice"`
} }
func (q *Queries) GetLessonByID(ctx context.Context, id int64) (GetLessonByIDRow, error) { func (q *Queries) GetLessonByID(ctx context.Context, id int64) (GetLessonByIDRow, error) {
@ -110,6 +118,7 @@ func (q *Queries) GetLessonByID(ctx context.Context, id int64) (GetLessonByIDRow
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.PublishStatus,
&i.HasPractice, &i.HasPractice,
) )
return i, err return i, err
@ -125,6 +134,7 @@ SELECT
l.thumbnail, l.thumbnail,
l.description, l.description,
l.sort_order, l.sort_order,
l.publish_status,
l.created_at, l.created_at,
l.updated_at, l.updated_at,
EXISTS ( EXISTS (
@ -137,6 +147,10 @@ FROM
lessons l lessons l
WHERE WHERE
l.module_id = $1 l.module_id = $1
AND (
$4::boolean = FALSE
OR l.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY ORDER BY
l.sort_order ASC, l.sort_order ASC,
l.id ASC l.id ASC
@ -145,27 +159,34 @@ OFFSET $3
` `
type ListLessonsByModuleIDParams struct { type ListLessonsByModuleIDParams struct {
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Limit int32 `json:"limit"` Limit int32 `json:"limit"`
Offset int32 `json:"offset"` Offset int32 `json:"offset"`
PublishedOnly bool `json:"published_only"`
} }
type ListLessonsByModuleIDRow struct { type ListLessonsByModuleIDRow struct {
TotalCount int64 `json:"total_count"` TotalCount int64 `json:"total_count"`
ID int64 `json:"id"` ID int64 `json:"id"`
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Title string `json:"title"` Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"` PublishStatus string `json:"publish_status"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
HasPractice bool `json:"has_practice"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
HasPractice bool `json:"has_practice"`
} }
func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByModuleIDParams) ([]ListLessonsByModuleIDRow, error) { func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByModuleIDParams) ([]ListLessonsByModuleIDRow, error) {
rows, err := q.db.Query(ctx, ListLessonsByModuleID, arg.ModuleID, arg.Limit, arg.Offset) rows, err := q.db.Query(ctx, ListLessonsByModuleID,
arg.ModuleID,
arg.Limit,
arg.Offset,
arg.PublishedOnly,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -182,6 +203,7 @@ func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByMo
&i.Thumbnail, &i.Thumbnail,
&i.Description, &i.Description,
&i.SortOrder, &i.SortOrder,
&i.PublishStatus,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.HasPractice, &i.HasPractice,
@ -204,20 +226,22 @@ SET
thumbnail = COALESCE($3::text, thumbnail), thumbnail = COALESCE($3::text, thumbnail),
description = COALESCE($4::text, description), description = COALESCE($4::text, description),
sort_order = coalesce($5::int, sort_order), sort_order = coalesce($5::int, sort_order),
publish_status = COALESCE($6::varchar, publish_status),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE WHERE
id = $6 id = $7
RETURNING RETURNING
id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order, publish_status
` `
type UpdateLessonParams struct { type UpdateLessonParams struct {
Title pgtype.Text `json:"title"` Title pgtype.Text `json:"title"`
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
SortOrder pgtype.Int4 `json:"sort_order"` SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"` PublishStatus pgtype.Text `json:"publish_status"`
ID int64 `json:"id"`
} }
func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Lesson, error) { func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Lesson, error) {
@ -227,6 +251,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les
arg.Thumbnail, arg.Thumbnail,
arg.Description, arg.Description,
arg.SortOrder, arg.SortOrder,
arg.PublishStatus,
arg.ID, arg.ID,
) )
var i Lesson var i Lesson
@ -240,6 +265,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.PublishStatus,
) )
return i, err return i, err
} }

193
gen/db/lms_personas.sql.go Normal file
View File

@ -0,0 +1,193 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_personas.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateLmsPersona = `-- name: CreateLmsPersona :one
INSERT INTO lms_personas (name, description, profile_picture, gender, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, description, profile_picture, is_active, created_at, updated_at, gender
`
type CreateLmsPersonaParams struct {
Name string `json:"name"`
Description pgtype.Text `json:"description"`
ProfilePicture pgtype.Text `json:"profile_picture"`
Gender pgtype.Text `json:"gender"`
IsActive bool `json:"is_active"`
}
func (q *Queries) CreateLmsPersona(ctx context.Context, arg CreateLmsPersonaParams) (LmsPersona, error) {
row := q.db.QueryRow(ctx, CreateLmsPersona,
arg.Name,
arg.Description,
arg.ProfilePicture,
arg.Gender,
arg.IsActive,
)
var i LmsPersona
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.ProfilePicture,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
&i.Gender,
)
return i, err
}
const DeleteLmsPersona = `-- name: DeleteLmsPersona :exec
DELETE FROM lms_personas
WHERE id = $1
`
func (q *Queries) DeleteLmsPersona(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteLmsPersona, id)
return err
}
const GetLmsPersonaByID = `-- name: GetLmsPersonaByID :one
SELECT id, name, description, profile_picture, is_active, created_at, updated_at, gender
FROM lms_personas
WHERE id = $1
`
func (q *Queries) GetLmsPersonaByID(ctx context.Context, id int64) (LmsPersona, error) {
row := q.db.QueryRow(ctx, GetLmsPersonaByID, id)
var i LmsPersona
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.ProfilePicture,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
&i.Gender,
)
return i, err
}
const ListLmsPersonas = `-- name: ListLmsPersonas :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.name,
p.description,
p.profile_picture,
p.gender,
p.is_active,
p.created_at,
p.updated_at
FROM lms_personas p
WHERE (
$3::boolean = FALSE
OR p.is_active = TRUE
)
ORDER BY p.name ASC, p.created_at DESC
LIMIT $1 OFFSET $2
`
type ListLmsPersonasParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
FilterActive bool `json:"filter_active"`
}
type ListLmsPersonasRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
ProfilePicture pgtype.Text `json:"profile_picture"`
Gender pgtype.Text `json:"gender"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListLmsPersonas(ctx context.Context, arg ListLmsPersonasParams) ([]ListLmsPersonasRow, error) {
rows, err := q.db.Query(ctx, ListLmsPersonas, arg.Limit, arg.Offset, arg.FilterActive)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLmsPersonasRow
for rows.Next() {
var i ListLmsPersonasRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.Name,
&i.Description,
&i.ProfilePicture,
&i.Gender,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateLmsPersona = `-- name: UpdateLmsPersona :one
UPDATE lms_personas
SET
name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description),
profile_picture = COALESCE($3::text, profile_picture),
gender = COALESCE($4::text, gender),
is_active = COALESCE($5::boolean, is_active),
updated_at = CURRENT_TIMESTAMP
WHERE id = $6
RETURNING id, name, description, profile_picture, is_active, created_at, updated_at, gender
`
type UpdateLmsPersonaParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
ProfilePicture pgtype.Text `json:"profile_picture"`
Gender pgtype.Text `json:"gender"`
IsActive pgtype.Bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateLmsPersona(ctx context.Context, arg UpdateLmsPersonaParams) (LmsPersona, error) {
row := q.db.QueryRow(ctx, UpdateLmsPersona,
arg.Name,
arg.Description,
arg.ProfilePicture,
arg.Gender,
arg.IsActive,
arg.ID,
)
var i LmsPersona
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.ProfilePicture,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
&i.Gender,
)
return i, err
}

View File

@ -35,6 +35,7 @@ FROM
INNER JOIN modules m ON m.id = l.module_id INNER JOIN modules m ON m.id = l.module_id
WHERE WHERE
m.course_id = $1 m.course_id = $1
AND l.publish_status = 'PUBLISHED'
` `
// Lesson-based progress within a course (all modules). // Lesson-based progress within a course (all modules).
@ -52,6 +53,7 @@ FROM
lessons lessons
WHERE WHERE
module_id = $1 module_id = $1
AND publish_status = 'PUBLISHED'
` `
func (q *Queries) CountLessonsInModule(ctx context.Context, moduleID int64) (int32, error) { func (q *Queries) CountLessonsInModule(ctx context.Context, moduleID int64) (int32, error) {
@ -70,6 +72,7 @@ FROM
INNER JOIN courses c ON c.id = m.course_id INNER JOIN courses c ON c.id = m.course_id
WHERE WHERE
c.program_id = $1 c.program_id = $1
AND l.publish_status = 'PUBLISHED'
` `
// Lesson-based progress within a program (all courses). // Lesson-based progress within a program (all courses).
@ -191,6 +194,7 @@ FROM
WHERE WHERE
m.course_id = $1 m.course_id = $1
AND ulp.user_id = $2 AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED'
` `
type CountUserCompletedLessonsInCourseParams struct { type CountUserCompletedLessonsInCourseParams struct {
@ -214,6 +218,7 @@ FROM
WHERE WHERE
l.module_id = $1 l.module_id = $1
AND ulp.user_id = $2 AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED'
` `
type CountUserCompletedLessonsInModuleParams struct { type CountUserCompletedLessonsInModuleParams struct {
@ -239,6 +244,7 @@ FROM
WHERE WHERE
c.program_id = $1 c.program_id = $1
AND ulp.user_id = $2 AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED'
` `
type CountUserCompletedLessonsInProgramParams struct { type CountUserCompletedLessonsInProgramParams struct {
@ -423,13 +429,25 @@ func (q *Queries) GetPreviousCourseInProgram(ctx context.Context, id int64) (Cou
const GetPreviousLessonInModule = `-- name: GetPreviousLessonInModule :one const GetPreviousLessonInModule = `-- name: GetPreviousLessonInModule :one
SELECT SELECT
l2.id, l2.module_id, l2.title, l2.video_url, l2.thumbnail, l2.description, l2.created_at, l2.updated_at, l2.sort_order l2.id, l2.module_id, l2.title, l2.video_url, l2.thumbnail, l2.description, l2.created_at, l2.updated_at, l2.sort_order, l2.publish_status
FROM FROM
lessons AS l1 lessons AS l1
INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id
AND l2.sort_order = l1.sort_order - 1 AND l2.publish_status = 'PUBLISHED'
AND l1.publish_status = 'PUBLISHED'
AND (
l2.sort_order < l1.sort_order
OR (
l2.sort_order = l1.sort_order
AND l2.id < l1.id
)
)
WHERE WHERE
l1.id = $1 l1.id = $1
ORDER BY
l2.sort_order DESC,
l2.id DESC
LIMIT 1
` `
func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Lesson, error) { func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Lesson, error) {
@ -445,6 +463,7 @@ func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Less
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.PublishStatus,
) )
return i, err return i, err
} }

View File

@ -43,6 +43,20 @@ type Device struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type EmailTemplate struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Subject string `json:"subject"`
BodyText string `json:"body_text"`
BodyHtml string `json:"body_html"`
Variables []byte `json:"variables"`
IsSystem bool `json:"is_system"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ExamPrepCatalogCourse struct { type ExamPrepCatalogCourse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -121,15 +135,16 @@ type GlobalSetting struct {
} }
type Lesson struct { type Lesson struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Title string `json:"title"` Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
PublishStatus string `json:"publish_status"`
} }
type LevelToSubCourse struct { type LevelToSubCourse struct {
@ -137,6 +152,17 @@ type LevelToSubCourse struct {
SubCourseID int64 `json:"sub_course_id"` SubCourseID int64 `json:"sub_course_id"`
} }
type LmsPersona struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
ProfilePicture pgtype.Text `json:"profile_picture"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Gender pgtype.Text `json:"gender"`
}
type LmsPractice struct { type LmsPractice struct {
ID int64 `json:"id"` ID int64 `json:"id"`
CourseID pgtype.Int8 `json:"course_id"` CourseID pgtype.Int8 `json:"course_id"`
@ -433,6 +459,18 @@ type SubscriptionPlan struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type TeamInvitation struct {
ID int64 `json:"id"`
TeamMemberID int64 `json:"team_member_id"`
Token string `json:"token"`
Status string `json:"status"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
InvitedBy pgtype.Int8 `json:"invited_by"`
AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type TeamMember struct { type TeamMember struct {
ID int64 `json:"id"` ID int64 `json:"id"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`

View File

@ -0,0 +1,284 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: team_invitations.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AcceptTeamInvitation = `-- name: AcceptTeamInvitation :one
UPDATE team_invitations
SET status = 'accepted',
accepted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND status = 'pending'
RETURNING id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at
`
func (q *Queries) AcceptTeamInvitation(ctx context.Context, id int64) (TeamInvitation, error) {
row := q.db.QueryRow(ctx, AcceptTeamInvitation, id)
var i TeamInvitation
err := row.Scan(
&i.ID,
&i.TeamMemberID,
&i.Token,
&i.Status,
&i.ExpiresAt,
&i.InvitedBy,
&i.AcceptedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const CreateTeamInvitation = `-- name: CreateTeamInvitation :one
INSERT INTO team_invitations (
team_member_id,
token,
status,
expires_at,
invited_by,
updated_at
)
VALUES ($1, $2, 'pending', $3, $4, CURRENT_TIMESTAMP)
RETURNING id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at
`
type CreateTeamInvitationParams struct {
TeamMemberID int64 `json:"team_member_id"`
Token string `json:"token"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
InvitedBy pgtype.Int8 `json:"invited_by"`
}
func (q *Queries) CreateTeamInvitation(ctx context.Context, arg CreateTeamInvitationParams) (TeamInvitation, error) {
row := q.db.QueryRow(ctx, CreateTeamInvitation,
arg.TeamMemberID,
arg.Token,
arg.ExpiresAt,
arg.InvitedBy,
)
var i TeamInvitation
err := row.Scan(
&i.ID,
&i.TeamMemberID,
&i.Token,
&i.Status,
&i.ExpiresAt,
&i.InvitedBy,
&i.AcceptedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ExpireTeamInvitation = `-- name: ExpireTeamInvitation :exec
UPDATE team_invitations
SET status = 'expired',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND status = 'pending'
`
func (q *Queries) ExpireTeamInvitation(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, ExpireTeamInvitation, id)
return err
}
const GetPendingTeamInvitationByMemberID = `-- name: GetPendingTeamInvitationByMemberID :one
SELECT id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at FROM team_invitations
WHERE team_member_id = $1
AND status = 'pending'
ORDER BY created_at DESC
LIMIT 1
`
func (q *Queries) GetPendingTeamInvitationByMemberID(ctx context.Context, teamMemberID int64) (TeamInvitation, error) {
row := q.db.QueryRow(ctx, GetPendingTeamInvitationByMemberID, teamMemberID)
var i TeamInvitation
err := row.Scan(
&i.ID,
&i.TeamMemberID,
&i.Token,
&i.Status,
&i.ExpiresAt,
&i.InvitedBy,
&i.AcceptedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetTeamInvitationByID = `-- name: GetTeamInvitationByID :one
SELECT id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at FROM team_invitations
WHERE id = $1
`
func (q *Queries) GetTeamInvitationByID(ctx context.Context, id int64) (TeamInvitation, error) {
row := q.db.QueryRow(ctx, GetTeamInvitationByID, id)
var i TeamInvitation
err := row.Scan(
&i.ID,
&i.TeamMemberID,
&i.Token,
&i.Status,
&i.ExpiresAt,
&i.InvitedBy,
&i.AcceptedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetTeamInvitationByToken = `-- name: GetTeamInvitationByToken :one
SELECT id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at FROM team_invitations
WHERE token = $1
`
func (q *Queries) GetTeamInvitationByToken(ctx context.Context, token string) (TeamInvitation, error) {
row := q.db.QueryRow(ctx, GetTeamInvitationByToken, token)
var i TeamInvitation
err := row.Scan(
&i.ID,
&i.TeamMemberID,
&i.Token,
&i.Status,
&i.ExpiresAt,
&i.InvitedBy,
&i.AcceptedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ListTeamInvitations = `-- name: ListTeamInvitations :many
SELECT
ti.id,
ti.team_member_id,
ti.token,
ti.status,
ti.expires_at,
ti.invited_by,
ti.accepted_at,
ti.created_at,
ti.updated_at,
tm.email,
tm.first_name,
tm.last_name,
tm.team_role,
COUNT(*) OVER () AS total_count
FROM team_invitations ti
INNER JOIN team_members tm ON tm.id = ti.team_member_id
WHERE ($1::text IS NULL OR ti.status = $1::text)
ORDER BY ti.created_at DESC
LIMIT $3::INT
OFFSET $2::INT
`
type ListTeamInvitationsParams struct {
Status pgtype.Text `json:"status"`
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type ListTeamInvitationsRow struct {
ID int64 `json:"id"`
TeamMemberID int64 `json:"team_member_id"`
Token string `json:"token"`
Status string `json:"status"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
InvitedBy pgtype.Int8 `json:"invited_by"`
AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
TeamRole string `json:"team_role"`
TotalCount int64 `json:"total_count"`
}
func (q *Queries) ListTeamInvitations(ctx context.Context, arg ListTeamInvitationsParams) ([]ListTeamInvitationsRow, error) {
rows, err := q.db.Query(ctx, ListTeamInvitations, arg.Status, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTeamInvitationsRow
for rows.Next() {
var i ListTeamInvitationsRow
if err := rows.Scan(
&i.ID,
&i.TeamMemberID,
&i.Token,
&i.Status,
&i.ExpiresAt,
&i.InvitedBy,
&i.AcceptedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Email,
&i.FirstName,
&i.LastName,
&i.TeamRole,
&i.TotalCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const RevokePendingTeamInvitationsForMember = `-- name: RevokePendingTeamInvitationsForMember :exec
UPDATE team_invitations
SET status = 'revoked',
updated_at = CURRENT_TIMESTAMP
WHERE team_member_id = $1
AND status = 'pending'
`
func (q *Queries) RevokePendingTeamInvitationsForMember(ctx context.Context, teamMemberID int64) error {
_, err := q.db.Exec(ctx, RevokePendingTeamInvitationsForMember, teamMemberID)
return err
}
const RevokeTeamInvitation = `-- name: RevokeTeamInvitation :one
UPDATE team_invitations
SET status = 'revoked',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND status = 'pending'
RETURNING id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at
`
func (q *Queries) RevokeTeamInvitation(ctx context.Context, id int64) (TeamInvitation, error) {
row := q.db.QueryRow(ctx, RevokeTeamInvitation, id)
var i TeamInvitation
err := row.Scan(
&i.ID,
&i.TeamMemberID,
&i.Token,
&i.Status,
&i.ExpiresAt,
&i.InvitedBy,
&i.AcceptedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -4,6 +4,7 @@ import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
customlogger "Yimaru-Backend/internal/logger" customlogger "Yimaru-Backend/internal/logger"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"os" "os"
"strconv" "strconv"
@ -134,6 +135,8 @@ type Config struct {
TELEBIRR TELEBIRRConfig `mapstructure:"telebirr_config"` TELEBIRR TELEBIRRConfig `mapstructure:"telebirr_config"`
ResendApiKey string ResendApiKey string
ResendSenderEmail string ResendSenderEmail string
TeamInviteBaseURL string
TeamInviteExpiry time.Duration
RedisAddr string RedisAddr string
KafkaBrokers []string KafkaBrokers []string
FCMServiceAccountKey string FCMServiceAccountKey string
@ -475,7 +478,23 @@ func (c *Config) loadEnv() error {
} }
c.ResendSenderEmail = resendSenderEmail c.ResendSenderEmail = resendSenderEmail
c.FCMServiceAccountKey = os.Getenv("FCM_SERVICE_ACCOUNT_KEY") c.TeamInviteBaseURL = strings.TrimSpace(os.Getenv("TEAM_INVITE_BASE_URL"))
inviteExpiryHours := 168
if raw := strings.TrimSpace(os.Getenv("TEAM_INVITE_EXPIRY_HOURS")); raw != "" {
if h, err := strconv.Atoi(raw); err == nil && h > 0 {
inviteExpiryHours = h
}
}
c.TeamInviteExpiry = time.Duration(inviteExpiryHours) * time.Hour
c.FCMServiceAccountKey = strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_KEY"))
if fp := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_KEY_FILE")); fp != "" {
raw, err := os.ReadFile(fp)
if err != nil {
return fmt.Errorf("read FCM_SERVICE_ACCOUNT_KEY_FILE %q: %w", fp, err)
}
c.FCMServiceAccountKey = strings.TrimSpace(string(raw))
}
// Vimeo configuration // Vimeo configuration
vimeoEnabled := os.Getenv("VIMEO_ENABLED") vimeoEnabled := os.Getenv("VIMEO_ENABLED")

View File

@ -25,6 +25,7 @@ const (
ActionUserDeleted ActivityAction = "USER_DELETED" ActionUserDeleted ActivityAction = "USER_DELETED"
ActionSettingsUpdated ActivityAction = "SETTINGS_UPDATED" ActionSettingsUpdated ActivityAction = "SETTINGS_UPDATED"
ActionTeamMemberCreated ActivityAction = "TEAM_MEMBER_CREATED" ActionTeamMemberCreated ActivityAction = "TEAM_MEMBER_CREATED"
ActionTeamMemberInvited ActivityAction = "TEAM_MEMBER_INVITED"
ActionTeamMemberUpdated ActivityAction = "TEAM_MEMBER_UPDATED" ActionTeamMemberUpdated ActivityAction = "TEAM_MEMBER_UPDATED"
ActionTeamMemberDeleted ActivityAction = "TEAM_MEMBER_DELETED" ActionTeamMemberDeleted ActivityAction = "TEAM_MEMBER_DELETED"
ActionCategoryCreated ActivityAction = "CATEGORY_CREATED" ActionCategoryCreated ActivityAction = "CATEGORY_CREATED"

67
internal/domain/chapa.go Normal file
View File

@ -0,0 +1,67 @@
package domain
// ChapaInitializeRequest is sent to POST /transaction/initialize.
type ChapaInitializeRequest struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
PhoneNumber string `json:"phone_number,omitempty"`
TxRef string `json:"tx_ref"`
CallbackURL string `json:"callback_url,omitempty"`
ReturnURL string `json:"return_url,omitempty"`
Customization struct {
Title string `json:"title"`
Description string `json:"description"`
} `json:"customization,omitempty"`
}
type ChapaInitializeResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data struct {
CheckoutURL string `json:"checkout_url"`
} `json:"data"`
}
type ChapaVerifyResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data ChapaTransactionData `json:"data"`
}
type ChapaTransactionData struct {
TxRef string `json:"tx_ref"`
Reference string `json:"reference"`
Amount string `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"`
PaymentMethod string `json:"payment_method"`
Mode string `json:"mode"`
}
// ChapaWebhookPayload is the body POSTed to the webhook URL.
type ChapaWebhookPayload struct {
Event string `json:"event"`
Type string `json:"type"`
TxRef string `json:"tx_ref"`
Reference string `json:"reference"`
Status string `json:"status"`
Amount string `json:"amount"`
Currency string `json:"currency"`
PaymentMethod string `json:"payment_method"`
Mode string `json:"mode"`
}
// ChapaCallbackQuery is sent to callback_url after payment (GET).
type ChapaCallbackQuery struct {
TrxRef string `json:"trx_ref"`
RefID string `json:"ref_id"`
Status string `json:"status"`
}
type ChapaPaymentMethod struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
}

View File

@ -0,0 +1,58 @@
package domain
import "time"
const (
EmailTemplateStatusActive = "ACTIVE"
EmailTemplateStatusInactive = "INACTIVE"
EmailTemplateSlugOTP = "otp"
EmailTemplateSlugInvitation = "invitation"
EmailTemplateSlugPasswordReset = "password_reset"
EmailTemplateSlugWelcome = "welcome"
EmailTemplateSlugCustomMessage = "custom_message"
)
type EmailTemplate struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Subject string `json:"subject"`
BodyText string `json:"body_text"`
BodyHTML string `json:"body_html"`
Variables []string `json:"variables"`
IsSystem bool `json:"is_system"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type RenderedEmail struct {
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html"`
}
type CreateEmailTemplateInput struct {
Slug string
Name string
Subject string
BodyText string
BodyHTML string
Variables []string
Status *string
}
type UpdateEmailTemplateInput struct {
Name *string
Subject *string
BodyText *string
BodyHTML *string
Variables []string
Status *string
}
type PreviewEmailTemplateInput struct {
Slug string
Variables map[string]any
}

View File

@ -9,7 +9,7 @@ type ExamPrepPractice struct {
Title string `json:"title"` Title string `json:"title"`
StoryDescription *string `json:"story_description,omitempty"` StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"` StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"` PersonaID *int64 `json:"persona_id,omitempty"` // lms_personas.id when set
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
PublishStatus PracticePublishStatus `json:"publish_status"` PublishStatus PracticePublishStatus `json:"publish_status"`
QuickTips *string `json:"quick_tips,omitempty"` QuickTips *string `json:"quick_tips,omitempty"`
@ -24,7 +24,7 @@ func (p ExamPrepPractice) VisibleToLearners() bool {
// CreateExamPrepPracticeInput is the body for POST .../exam-prep/lessons/{lessonId}/practices (lesson from path). // CreateExamPrepPracticeInput is the body for POST .../exam-prep/lessons/{lessonId}/practices (lesson from path).
type CreateExamPrepPracticeInput struct { type CreateExamPrepPracticeInput struct {
Title string `json:"title" validate:"required"` Title *string `json:"title,omitempty"`
StoryDescription *string `json:"story_description,omitempty"` StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"` StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"` PersonaID *int64 `json:"persona_id,omitempty"`

View File

@ -22,6 +22,8 @@ type CreateExamPrepUnitInput struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
// SortOrder within the catalog course when set; omit to append after current max sort_order within catalog_course_id.
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
} }
type UpdateExamPrepUnitInput struct { type UpdateExamPrepUnitInput struct {

View File

@ -1,20 +1,55 @@
package domain package domain
import "time" import (
"strings"
"time"
)
// LessonPublishStatus controls learner visibility for an LMS lesson row (like PracticePublishStatus).
type LessonPublishStatus string
const (
LessonPublishDraft LessonPublishStatus = "DRAFT"
LessonPublishPublished LessonPublishStatus = "PUBLISHED"
)
// LessonPublishStatusFromDB normalizes persisted values.
func LessonPublishStatusFromDB(raw string) LessonPublishStatus {
switch strings.TrimSpace(strings.ToUpper(raw)) {
case string(LessonPublishPublished):
return LessonPublishPublished
default:
return LessonPublishDraft
}
}
// LessonPublishStatusFromCreateInput resolves create body: omit → draft; explicit value validated separately.
func LessonPublishStatusFromCreateInput(raw *string) LessonPublishStatus {
if raw == nil || strings.TrimSpace(*raw) == "" {
return LessonPublishDraft
}
return LessonPublishStatusFromDB(*raw)
}
// Lesson belongs to a Module. // Lesson belongs to a Module.
type Lesson struct { type Lesson struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Title string `json:"title"` Title string `json:"title"`
VideoURL *string `json:"video_url,omitempty"` VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
HasPractice bool `json:"has_practice"` PublishStatus LessonPublishStatus `json:"publish_status"`
CreatedAt time.Time `json:"created_at"` HasPractice bool `json:"has_practice"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` CreatedAt time.Time `json:"created_at"`
Access *LMSEntityAccess `json:"access,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
}
// VisibleToLearners is true when the lesson appears in subscriber/catalog LMS APIs.
func (l Lesson) VisibleToLearners() bool {
return l.PublishStatus == LessonPublishPublished
} }
type CreateLessonInput struct { type CreateLessonInput struct {
@ -22,12 +57,17 @@ type CreateLessonInput struct {
VideoURL *string `json:"video_url,omitempty"` VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
// SortOrder within the module when set; omit to append after current max within module_id.
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
// Omit or empty defaults to DRAFT; set PUBLISHED to make visible to learners immediately.
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
} }
type UpdateLessonInput struct { type UpdateLessonInput struct {
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
VideoURL *string `json:"video_url,omitempty"` VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
SortOrder *int `json:"sort_order,omitempty"` SortOrder *int `json:"sort_order,omitempty"`
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
} }

View File

@ -0,0 +1,39 @@
package domain
import (
"errors"
"time"
)
// ErrPersonaNotFound is returned when an lms_personas row does not exist.
var ErrPersonaNotFound = errors.New("persona not found")
// LmsPersona is a coach / character profile stored in lms_personas and referenced by practice shells.
type LmsPersona struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
// ProfilePicture is always serialized (null when not set); clients rely on stable keys in list payloads.
ProfilePicture *string `json:"profile_picture"` // image URL (e.g. MinIO or HTTPS); JSON null when unset
// Gender matches learner-style free text (nullable); always present in JSON for stable list payloads.
Gender *string `json:"gender"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type CreateLmsPersonaInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
ProfilePicture *string `json:"profile_picture,omitempty"`
Gender *string `json:"gender,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
type UpdateLmsPersonaInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
ProfilePicture *string `json:"profile_picture,omitempty"`
Gender *string `json:"gender,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}

View File

@ -47,7 +47,7 @@ type Practice struct {
Title string `json:"title"` Title string `json:"title"`
StoryDescription *string `json:"story_description,omitempty"` StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"` StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"` PersonaID *int64 `json:"persona_id,omitempty"` // lms_personas.id when set
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
PublishStatus PracticePublishStatus `json:"publish_status"` PublishStatus PracticePublishStatus `json:"publish_status"`
QuickTips *string `json:"quick_tips,omitempty"` QuickTips *string `json:"quick_tips,omitempty"`
@ -63,7 +63,7 @@ func (p Practice) VisibleToLearners() bool {
type CreatePracticeInput struct { type CreatePracticeInput struct {
ParentKind ParentKind `json:"parent_kind" validate:"required,oneof=COURSE MODULE LESSON"` ParentKind ParentKind `json:"parent_kind" validate:"required,oneof=COURSE MODULE LESSON"`
ParentID int64 `json:"parent_id" validate:"required,gt=0"` ParentID int64 `json:"parent_id" validate:"required,gt=0"`
Title string `json:"title" validate:"required"` Title *string `json:"title,omitempty"`
StoryDescription *string `json:"story_description,omitempty"` StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"` StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"` PersonaID *int64 `json:"persona_id,omitempty"`

View File

@ -0,0 +1,79 @@
package domain
import (
"errors"
"time"
)
var (
ErrTeamInvitationNotFound = errors.New("team invitation not found")
ErrTeamInvitationExpired = errors.New("team invitation has expired")
ErrTeamInvitationAlreadyUsed = errors.New("team invitation has already been accepted")
ErrTeamInvitationRevoked = errors.New("team invitation has been revoked")
ErrTeamMemberPendingInvitation = errors.New("team member must accept their invitation before signing in")
ErrTeamInviteBaseURLNotConfigured = errors.New("team invite base URL is not configured")
)
type TeamInvitationStatus string
const (
TeamInvitationStatusPending TeamInvitationStatus = "pending"
TeamInvitationStatusAccepted TeamInvitationStatus = "accepted"
TeamInvitationStatusExpired TeamInvitationStatus = "expired"
TeamInvitationStatusRevoked TeamInvitationStatus = "revoked"
)
type TeamInvitation struct {
ID int64
TeamMemberID int64
Token string
Status TeamInvitationStatus
ExpiresAt time.Time
InvitedBy *int64
AcceptedAt *time.Time
CreatedAt time.Time
UpdatedAt *time.Time
}
type TeamInvitationWithMember struct {
TeamInvitation
Email string
FirstName string
LastName string
TeamRole TeamRole
}
type InviteTeamMemberReq struct {
FirstName string `json:"first_name" validate:"required"`
LastName string `json:"last_name" validate:"required"`
Email string `json:"email" validate:"required,email"`
PhoneNumber string `json:"phone_number"`
TeamRole string `json:"team_role" validate:"required"`
Department string `json:"department"`
JobTitle string `json:"job_title"`
EmploymentType string `json:"employment_type"`
HireDate string `json:"hire_date"`
Permissions []string `json:"permissions"`
}
type AcceptTeamInvitationReq struct {
Token string `json:"token" validate:"required"`
Password string `json:"password" validate:"required,min=8"`
}
type VerifyTeamInvitationRes struct {
Valid bool `json:"valid"`
Email string `json:"email,omitempty"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
TeamRole string `json:"team_role,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Status string `json:"status,omitempty"`
}
type InviteTeamMemberRes struct {
InvitationID int64 `json:"invitation_id"`
TeamMemberID int64 `json:"team_member_id"`
Email string `json:"email"`
ExpiresAt string `json:"expires_at"`
}

View File

@ -2,6 +2,7 @@ package helpers
import ( import (
random "crypto/rand" random "crypto/rand"
"encoding/hex"
"fmt" "fmt"
"strings" "strings"
@ -14,6 +15,14 @@ func GenerateID() string {
return uuid.New().String() return uuid.New().String()
} }
func GenerateInviteToken() (string, error) {
b := make([]byte, 32)
if _, err := random.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func GenerateOTP() string { func GenerateOTP() string {
num := 100000 + rand.UintN(899999) num := 100000 + rand.UintN(899999)
return fmt.Sprintf("%d", num) // 6 digit random number [100,000 - 999,999] return fmt.Sprintf("%d", num) // 6 digit random number [100,000 - 999,999]

View File

@ -0,0 +1,15 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type EmailTemplateStore interface {
CreateEmailTemplate(ctx context.Context, input domain.CreateEmailTemplateInput) (domain.EmailTemplate, error)
UpdateEmailTemplate(ctx context.Context, id int64, input domain.UpdateEmailTemplateInput) (domain.EmailTemplate, error)
GetEmailTemplateByID(ctx context.Context, id int64, includeInactive bool) (domain.EmailTemplate, error)
GetEmailTemplateBySlug(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error)
ListEmailTemplates(ctx context.Context, status *string, query *string, limit int32, offset int32) ([]domain.EmailTemplate, int64, error)
DeleteEmailTemplate(ctx context.Context, id int64) error
}

View File

@ -8,7 +8,7 @@ import (
type LessonStore interface { type LessonStore interface {
CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error) CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error)
GetLessonByID(ctx context.Context, id int64) (domain.Lesson, error) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, error)
ListLessonsByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) ListLessonsByModuleID(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Lesson, int64, error)
UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error)
DeleteLesson(ctx context.Context, id int64) error DeleteLesson(ctx context.Context, id int64) error
} }

View File

@ -0,0 +1,21 @@
package ports
import (
"context"
"Yimaru-Backend/internal/domain"
)
// LmsPersonaReader resolves catalog personas referenced by LMS / exam-prep practices.
type LmsPersonaReader interface {
GetLmsPersonaByID(ctx context.Context, id int64) (domain.LmsPersona, error)
}
// LmsPersonaStore is full CRUD for lms_personas.
type LmsPersonaStore interface {
LmsPersonaReader
CreateLmsPersona(ctx context.Context, in domain.CreateLmsPersonaInput) (domain.LmsPersona, error)
UpdateLmsPersona(ctx context.Context, id int64, in domain.UpdateLmsPersonaInput) (domain.LmsPersona, error)
DeleteLmsPersona(ctx context.Context, id int64) error
ListLmsPersonas(ctx context.Context, activeOnly bool, limit, offset int32) ([]domain.LmsPersona, int64, error)
}

View File

@ -38,4 +38,14 @@ type TeamStore interface {
RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error
BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error)
BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error)
CreateTeamInvitation(ctx context.Context, invitation domain.TeamInvitation) (domain.TeamInvitation, error)
GetTeamInvitationByToken(ctx context.Context, token string) (domain.TeamInvitation, error)
GetTeamInvitationByID(ctx context.Context, id int64) (domain.TeamInvitation, error)
GetPendingTeamInvitationByMemberID(ctx context.Context, memberID int64) (domain.TeamInvitation, error)
RevokePendingTeamInvitationsForMember(ctx context.Context, memberID int64) error
AcceptTeamInvitation(ctx context.Context, invitationID int64) (domain.TeamInvitation, error)
RevokeTeamInvitation(ctx context.Context, invitationID int64) (domain.TeamInvitation, error)
ExpireTeamInvitation(ctx context.Context, invitationID int64) error
ListTeamInvitations(ctx context.Context, status *string, limit, offset int32) ([]domain.TeamInvitationWithMember, int64, error)
} }

View File

@ -0,0 +1,230 @@
package repository
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"encoding/json"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func NewEmailTemplateStore(s *Store) ports.EmailTemplateStore { return s }
func emailTemplateToDomain(
id int64,
slug string,
name string,
subject string,
bodyText string,
bodyHTML string,
variables []byte,
isSystem bool,
status string,
createdAt pgtype.Timestamptz,
updatedAt pgtype.Timestamptz,
) (domain.EmailTemplate, error) {
var vars []string
if len(variables) > 0 {
if err := json.Unmarshal(variables, &vars); err != nil {
return domain.EmailTemplate{}, err
}
}
if vars == nil {
vars = []string{}
}
return domain.EmailTemplate{
ID: id,
Slug: slug,
Name: name,
Subject: subject,
BodyText: bodyText,
BodyHTML: bodyHTML,
Variables: vars,
IsSystem: isSystem,
Status: status,
CreatedAt: createdAt.Time,
UpdatedAt: timePtr(updatedAt),
}, nil
}
func marshalEmailTemplateVariables(vars []string) ([]byte, error) {
if vars == nil {
vars = []string{}
}
return json.Marshal(vars)
}
func (s *Store) CreateEmailTemplate(ctx context.Context, input domain.CreateEmailTemplateInput) (domain.EmailTemplate, error) {
status := domain.EmailTemplateStatusActive
if input.Status != nil {
status = *input.Status
}
variablesJSON, err := marshalEmailTemplateVariables(input.Variables)
if err != nil {
return domain.EmailTemplate{}, err
}
row := s.conn.QueryRow(ctx, `
INSERT INTO email_templates (slug, name, subject, body_text, body_html, variables, is_system, status)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, FALSE, $7)
RETURNING id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at
`, input.Slug, input.Name, input.Subject, input.BodyText, input.BodyHTML, variablesJSON, status)
return scanEmailTemplateRow(row)
}
func (s *Store) UpdateEmailTemplate(ctx context.Context, id int64, input domain.UpdateEmailTemplateInput) (domain.EmailTemplate, error) {
variablesSet := input.Variables != nil
variablesJSON, err := marshalEmailTemplateVariables(input.Variables)
if err != nil {
return domain.EmailTemplate{}, err
}
row := s.conn.QueryRow(ctx, `
UPDATE email_templates
SET name = COALESCE($2, name),
subject = COALESCE($3, subject),
body_text = COALESCE($4, body_text),
body_html = COALESCE($5, body_html),
variables = CASE WHEN $6::boolean THEN $7::jsonb ELSE variables END,
status = COALESCE($8, status),
updated_at = NOW()
WHERE id = $1
RETURNING id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at
`, id, input.Name, input.Subject, input.BodyText, input.BodyHTML, variablesSet, variablesJSON, input.Status)
return scanEmailTemplateRow(row)
}
func (s *Store) GetEmailTemplateByID(ctx context.Context, id int64, includeInactive bool) (domain.EmailTemplate, error) {
row := s.conn.QueryRow(ctx, `
SELECT id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at
FROM email_templates
WHERE id = $1
AND ($2::boolean = TRUE OR status = 'ACTIVE')
`, id, includeInactive)
return scanEmailTemplateRow(row)
}
func (s *Store) GetEmailTemplateBySlug(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) {
row := s.conn.QueryRow(ctx, `
SELECT id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at
FROM email_templates
WHERE slug = $1
AND ($2::boolean = TRUE OR status = 'ACTIVE')
`, slug, includeInactive)
return scanEmailTemplateRow(row)
}
func (s *Store) ListEmailTemplates(ctx context.Context, status *string, query *string, limit int32, offset int32) ([]domain.EmailTemplate, int64, error) {
rows, err := s.conn.Query(ctx, `
SELECT id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at
FROM email_templates
WHERE ($1::text IS NULL OR status = $1)
AND (
$2::text IS NULL
OR slug ILIKE '%' || $2 || '%'
OR name ILIKE '%' || $2 || '%'
OR subject ILIKE '%' || $2 || '%'
)
ORDER BY is_system DESC, name ASC, id ASC
LIMIT $3 OFFSET $4
`, toPgText(status), toPgText(query), limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
templates := make([]domain.EmailTemplate, 0)
for rows.Next() {
tmpl, err := scanEmailTemplateRows(rows)
if err != nil {
return nil, 0, err
}
templates = append(templates, tmpl)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
var totalCount int64
if err := s.conn.QueryRow(ctx, `
SELECT COUNT(*)
FROM email_templates
WHERE ($1::text IS NULL OR status = $1)
AND (
$2::text IS NULL
OR slug ILIKE '%' || $2 || '%'
OR name ILIKE '%' || $2 || '%'
OR subject ILIKE '%' || $2 || '%'
)
`, toPgText(status), toPgText(query)).Scan(&totalCount); err != nil {
return nil, 0, err
}
return templates, totalCount, nil
}
func (s *Store) DeleteEmailTemplate(ctx context.Context, id int64) error {
cmd, err := s.conn.Exec(ctx, `
DELETE FROM email_templates
WHERE id = $1 AND is_system = FALSE
`, id)
if err != nil {
return err
}
if cmd.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
type emailTemplateScanner interface {
Scan(dest ...any) error
}
func scanEmailTemplateRow(row emailTemplateScanner) (domain.EmailTemplate, error) {
var (
id int64
slug string
name string
subject string
bodyText string
bodyHTML string
variables []byte
isSystem bool
status string
createdAt pgtype.Timestamptz
updatedAt pgtype.Timestamptz
)
if err := row.Scan(&id, &slug, &name, &subject, &bodyText, &bodyHTML, &variables, &isSystem, &status, &createdAt, &updatedAt); err != nil {
return domain.EmailTemplate{}, err
}
return emailTemplateToDomain(id, slug, name, subject, bodyText, bodyHTML, variables, isSystem, status, createdAt, updatedAt)
}
func scanEmailTemplateRows(rows pgx.Rows) (domain.EmailTemplate, error) {
var (
id int64
slug string
name string
subject string
bodyText string
bodyHTML string
variables []byte
isSystem bool
status string
createdAt pgtype.Timestamptz
updatedAt pgtype.Timestamptz
)
if err := rows.Scan(&id, &slug, &name, &subject, &bodyText, &bodyHTML, &variables, &isSystem, &status, &createdAt, &updatedAt); err != nil {
return domain.EmailTemplate{}, err
}
return emailTemplateToDomain(id, slug, name, subject, bodyText, bodyHTML, variables, isSystem, status, createdAt, updatedAt)
}

View File

@ -51,7 +51,7 @@ func (s *Store) CreateExamPrepLessonPractice(ctx context.Context, lessonID int64
ps := domain.ParsePracticePublishStatusInput(in.PublishStatus) ps := domain.ParsePracticePublishStatusInput(in.PublishStatus)
p, err := s.queries.ExamPrepCreateLessonPractice(ctx, dbgen.ExamPrepCreateLessonPracticeParams{ p, err := s.queries.ExamPrepCreateLessonPractice(ctx, dbgen.ExamPrepCreateLessonPracticeParams{
UnitModuleLessonID: lessonID, UnitModuleLessonID: lessonID,
Title: in.Title, Title: derefString(in.Title),
StoryDescription: toPgText(in.StoryDescription), StoryDescription: toPgText(in.StoryDescription),
StoryImage: toPgText(in.StoryImage), StoryImage: toPgText(in.StoryImage),
PersonaID: int64PtrToPg8(in.PersonaID), PersonaID: int64PtrToPg8(in.PersonaID),

View File

@ -29,11 +29,41 @@ func examPrepUnitToDomain(u dbgen.ExamPrepUnit) domain.ExamPrepUnit {
} }
func (s *Store) CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, input domain.CreateExamPrepUnitInput) (domain.ExamPrepUnit, error) { func (s *Store) CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, input domain.CreateExamPrepUnitInput) (domain.ExamPrepUnit, error) {
if input.SortOrder != nil {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return domain.ExamPrepUnit{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
target := int32(*input.SortOrder)
if _, err := tx.Exec(ctx,
`UPDATE exam_prep.units SET sort_order = sort_order + 1 WHERE catalog_course_id = $1 AND sort_order >= $2`,
catalogCourseID, target,
); err != nil {
return domain.ExamPrepUnit{}, err
}
u, err := q.ExamPrepCreateUnit(ctx, dbgen.ExamPrepCreateUnitParams{
CatalogCourseID: catalogCourseID,
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
SortOrder: pgtype.Int4{Int32: target, Valid: true},
})
if err != nil {
return domain.ExamPrepUnit{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.ExamPrepUnit{}, err
}
return examPrepUnitToDomain(u), nil
}
u, err := s.queries.ExamPrepCreateUnit(ctx, dbgen.ExamPrepCreateUnitParams{ u, err := s.queries.ExamPrepCreateUnit(ctx, dbgen.ExamPrepCreateUnitParams{
CatalogCourseID: catalogCourseID, CatalogCourseID: catalogCourseID,
Name: input.Name, Name: input.Name,
Description: toPgText(input.Description), Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail), Thumbnail: toPgText(input.Thumbnail),
SortOrder: pgtype.Int4{Valid: false},
}) })
if err != nil { if err != nil {
return domain.ExamPrepUnit{}, err return domain.ExamPrepUnit{}, err

View File

@ -13,9 +13,10 @@ import (
func lessonToDomain(l dbgen.Lesson) domain.Lesson { func lessonToDomain(l dbgen.Lesson) domain.Lesson {
out := domain.Lesson{ out := domain.Lesson{
ID: l.ID, ID: l.ID,
ModuleID: l.ModuleID, ModuleID: l.ModuleID,
Title: l.Title, Title: l.Title,
PublishStatus: domain.LessonPublishStatusFromDB(l.PublishStatus),
} }
out.VideoURL = fromPgText(l.VideoUrl) out.VideoURL = fromPgText(l.VideoUrl)
out.Thumbnail = fromPgText(l.Thumbnail) out.Thumbnail = fromPgText(l.Thumbnail)
@ -30,12 +31,47 @@ func lessonToDomain(l dbgen.Lesson) domain.Lesson {
} }
func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error) { func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error) {
pub := string(domain.LessonPublishStatusFromCreateInput(input.PublishStatus))
if input.SortOrder != nil {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return domain.Lesson{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
target := int32(*input.SortOrder)
if _, err := tx.Exec(ctx,
`UPDATE lessons SET sort_order = sort_order + 1 WHERE module_id = $1 AND sort_order >= $2`,
moduleID, target,
); err != nil {
return domain.Lesson{}, err
}
l, err := q.CreateLesson(ctx, dbgen.CreateLessonParams{
ModuleID: moduleID,
Title: input.Title,
VideoUrl: toPgText(input.VideoURL),
Thumbnail: toPgText(input.Thumbnail),
Description: toPgText(input.Description),
SortOrder: pgtype.Int4{Int32: target, Valid: true},
PublishStatus: pub,
})
if err != nil {
return domain.Lesson{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Lesson{}, err
}
return lessonToDomain(l), nil
}
l, err := s.queries.CreateLesson(ctx, dbgen.CreateLessonParams{ l, err := s.queries.CreateLesson(ctx, dbgen.CreateLessonParams{
ModuleID: moduleID, ModuleID: moduleID,
Title: input.Title, Title: input.Title,
VideoUrl: toPgText(input.VideoURL), VideoUrl: toPgText(input.VideoURL),
Thumbnail: toPgText(input.Thumbnail), Thumbnail: toPgText(input.Thumbnail),
Description: toPgText(input.Description), Description: toPgText(input.Description),
SortOrder: pgtype.Int4{Valid: false},
PublishStatus: pub,
}) })
if err != nil { if err != nil {
return domain.Lesson{}, err return domain.Lesson{}, err
@ -52,25 +88,27 @@ func (s *Store) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, err
return domain.Lesson{}, err return domain.Lesson{}, err
} }
out := lessonToDomain(dbgen.Lesson{ out := lessonToDomain(dbgen.Lesson{
ID: l.ID, ID: l.ID,
ModuleID: l.ModuleID, ModuleID: l.ModuleID,
Title: l.Title, Title: l.Title,
VideoUrl: l.VideoUrl, VideoUrl: l.VideoUrl,
Thumbnail: l.Thumbnail, Thumbnail: l.Thumbnail,
Description: l.Description, Description: l.Description,
SortOrder: l.SortOrder, SortOrder: l.SortOrder,
CreatedAt: l.CreatedAt, PublishStatus: l.PublishStatus,
UpdatedAt: l.UpdatedAt, CreatedAt: l.CreatedAt,
UpdatedAt: l.UpdatedAt,
}) })
out.HasPractice = l.HasPractice out.HasPractice = l.HasPractice
return out, nil return out, nil
} }
func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) { func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Lesson, int64, error) {
rows, err := s.queries.ListLessonsByModuleID(ctx, dbgen.ListLessonsByModuleIDParams{ rows, err := s.queries.ListLessonsByModuleID(ctx, dbgen.ListLessonsByModuleIDParams{
ModuleID: moduleID, ModuleID: moduleID,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
PublishedOnly: publishedOnly,
}) })
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -85,15 +123,16 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
total = r.TotalCount total = r.TotalCount
} }
lesson := lessonToDomain(dbgen.Lesson{ lesson := lessonToDomain(dbgen.Lesson{
ID: r.ID, ID: r.ID,
ModuleID: r.ModuleID, ModuleID: r.ModuleID,
Title: r.Title, Title: r.Title,
VideoUrl: r.VideoUrl, VideoUrl: r.VideoUrl,
Thumbnail: r.Thumbnail, Thumbnail: r.Thumbnail,
Description: r.Description, Description: r.Description,
CreatedAt: r.CreatedAt, SortOrder: r.SortOrder,
UpdatedAt: r.UpdatedAt, PublishStatus: r.PublishStatus,
SortOrder: r.SortOrder, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}) })
lesson.HasPractice = r.HasPractice lesson.HasPractice = r.HasPractice
out = append(out, lesson) out = append(out, lesson)
@ -103,6 +142,8 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) { func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) {
sortParam := optionalInt4Update(input.SortOrder) sortParam := optionalInt4Update(input.SortOrder)
pubParam := optionalPublishStatusUpdate(input.PublishStatus)
var titleText pgtype.Text var titleText pgtype.Text
if input.Title != nil { if input.Title != nil {
titleText = pgtype.Text{String: *input.Title, Valid: true} titleText = pgtype.Text{String: *input.Title, Valid: true}
@ -127,12 +168,13 @@ func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateL
return domain.Lesson{}, err return domain.Lesson{}, err
} }
l, err := q.UpdateLesson(ctx, dbgen.UpdateLessonParams{ l, err := q.UpdateLesson(ctx, dbgen.UpdateLessonParams{
ID: id, ID: id,
Title: titleText, Title: titleText,
VideoUrl: optionalTextUpdate(input.VideoURL), VideoUrl: optionalTextUpdate(input.VideoURL),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
SortOrder: pgtype.Int4{Valid: false}, SortOrder: pgtype.Int4{Valid: false},
PublishStatus: pubParam,
}) })
if err != nil { if err != nil {
return domain.Lesson{}, err return domain.Lesson{}, err
@ -148,12 +190,13 @@ func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateL
} }
l, err := s.queries.UpdateLesson(ctx, dbgen.UpdateLessonParams{ l, err := s.queries.UpdateLesson(ctx, dbgen.UpdateLessonParams{
ID: id, ID: id,
Title: titleText, Title: titleText,
VideoUrl: optionalTextUpdate(input.VideoURL), VideoUrl: optionalTextUpdate(input.VideoURL),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
SortOrder: sortParam, SortOrder: sortParam,
PublishStatus: pubParam,
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View File

@ -0,0 +1,119 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func lmsPersonaToDomain(p dbgen.LmsPersona) domain.LmsPersona {
out := domain.LmsPersona{
ID: p.ID,
Name: p.Name,
IsActive: p.IsActive,
}
out.Description = fromPgText(p.Description)
out.ProfilePicture = fromPgText(p.ProfilePicture)
out.Gender = fromPgText(p.Gender)
out.CreatedAt = p.CreatedAt.Time
if p.UpdatedAt.Valid {
t := p.UpdatedAt.Time
out.UpdatedAt = &t
}
return out
}
func optionalBoolUpdatePB(v *bool) pgtype.Bool {
if v == nil {
return pgtype.Bool{Valid: false}
}
return pgtype.Bool{Bool: *v, Valid: true}
}
func (s *Store) CreateLmsPersona(ctx context.Context, in domain.CreateLmsPersonaInput) (domain.LmsPersona, error) {
active := true
if in.IsActive != nil {
active = *in.IsActive
}
p, err := s.queries.CreateLmsPersona(ctx, dbgen.CreateLmsPersonaParams{
Name: in.Name,
Description: toPgText(in.Description),
ProfilePicture: toPgText(in.ProfilePicture),
Gender: toPgText(in.Gender),
IsActive: active,
})
if err != nil {
return domain.LmsPersona{}, err
}
return lmsPersonaToDomain(p), nil
}
func (s *Store) GetLmsPersonaByID(ctx context.Context, id int64) (domain.LmsPersona, error) {
p, err := s.queries.GetLmsPersonaByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.LmsPersona{}, pgx.ErrNoRows
}
return domain.LmsPersona{}, err
}
return lmsPersonaToDomain(p), nil
}
func (s *Store) UpdateLmsPersona(ctx context.Context, id int64, in domain.UpdateLmsPersonaInput) (domain.LmsPersona, error) {
p, err := s.queries.UpdateLmsPersona(ctx, dbgen.UpdateLmsPersonaParams{
ID: id,
Name: optionalTextUpdate(in.Name),
Description: optionalTextUpdate(in.Description),
ProfilePicture: optionalTextUpdate(in.ProfilePicture),
Gender: optionalTextUpdate(in.Gender),
IsActive: optionalBoolUpdatePB(in.IsActive),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.LmsPersona{}, pgx.ErrNoRows
}
return domain.LmsPersona{}, err
}
return lmsPersonaToDomain(p), nil
}
func (s *Store) DeleteLmsPersona(ctx context.Context, id int64) error {
return s.queries.DeleteLmsPersona(ctx, id)
}
func (s *Store) ListLmsPersonas(ctx context.Context, activeOnly bool, limit, offset int32) ([]domain.LmsPersona, int64, error) {
rows, err := s.queries.ListLmsPersonas(ctx, dbgen.ListLmsPersonasParams{
Limit: limit,
Offset: offset,
FilterActive: activeOnly,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.LmsPersona{}, 0, nil
}
var total int64
out := make([]domain.LmsPersona, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, lmsPersonaToDomain(dbgen.LmsPersona{
ID: r.ID,
Name: r.Name,
Description: r.Description,
ProfilePicture: r.ProfilePicture,
Gender: r.Gender,
IsActive: r.IsActive,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}))
}
return out, total, nil
}

View File

@ -33,6 +33,13 @@ func optionalInt8UpdateID(val *int64) pgtype.Int8 {
return pgtype.Int8{Int64: *val, Valid: true} return pgtype.Int8{Int64: *val, Valid: true}
} }
func derefString(p *string) string {
if p == nil {
return ""
}
return *p
}
func lmsPracticeToDomain(p dbgen.LmsPractice) domain.Practice { func lmsPracticeToDomain(p dbgen.LmsPractice) domain.Practice {
out := domain.Practice{ out := domain.Practice{
ID: p.ID, ID: p.ID,
@ -98,7 +105,7 @@ func (s *Store) CreateLmsPractice(
CourseID: int64PtrToPg8(courseID), CourseID: int64PtrToPg8(courseID),
ModuleID: int64PtrToPg8(moduleID), ModuleID: int64PtrToPg8(moduleID),
LessonID: int64PtrToPg8(lessonID), LessonID: int64PtrToPg8(lessonID),
Title: in.Title, Title: derefString(in.Title),
StoryDescription: toPgText(in.StoryDescription), StoryDescription: toPgText(in.StoryDescription),
StoryImage: toPgText(in.StoryImage), StoryImage: toPgText(in.StoryImage),
PersonaID: int64PtrToPg8(in.PersonaID), PersonaID: int64PtrToPg8(in.PersonaID),

View File

@ -0,0 +1,152 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func mapDBTeamInvitation(row dbgen.TeamInvitation) domain.TeamInvitation {
inv := domain.TeamInvitation{
ID: row.ID,
TeamMemberID: row.TeamMemberID,
Token: row.Token,
Status: domain.TeamInvitationStatus(row.Status),
ExpiresAt: row.ExpiresAt.Time,
CreatedAt: row.CreatedAt.Time,
}
if row.InvitedBy.Valid {
inv.InvitedBy = &row.InvitedBy.Int64
}
if row.AcceptedAt.Valid {
t := row.AcceptedAt.Time
inv.AcceptedAt = &t
}
if row.UpdatedAt.Valid {
t := row.UpdatedAt.Time
inv.UpdatedAt = &t
}
return inv
}
func (s *Store) CreateTeamInvitation(ctx context.Context, invitation domain.TeamInvitation) (domain.TeamInvitation, error) {
var invitedBy pgtype.Int8
if invitation.InvitedBy != nil {
invitedBy = pgtype.Int8{Int64: *invitation.InvitedBy, Valid: true}
}
row, err := s.queries.CreateTeamInvitation(ctx, dbgen.CreateTeamInvitationParams{
TeamMemberID: invitation.TeamMemberID,
Token: invitation.Token,
ExpiresAt: pgtype.Timestamptz{Time: invitation.ExpiresAt, Valid: true},
InvitedBy: invitedBy,
})
if err != nil {
return domain.TeamInvitation{}, err
}
return mapDBTeamInvitation(row), nil
}
func (s *Store) GetTeamInvitationByToken(ctx context.Context, token string) (domain.TeamInvitation, error) {
row, err := s.queries.GetTeamInvitationByToken(ctx, token)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.TeamInvitation{}, domain.ErrTeamInvitationNotFound
}
return domain.TeamInvitation{}, err
}
return mapDBTeamInvitation(row), nil
}
func (s *Store) GetTeamInvitationByID(ctx context.Context, id int64) (domain.TeamInvitation, error) {
row, err := s.queries.GetTeamInvitationByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.TeamInvitation{}, domain.ErrTeamInvitationNotFound
}
return domain.TeamInvitation{}, err
}
return mapDBTeamInvitation(row), nil
}
func (s *Store) GetPendingTeamInvitationByMemberID(ctx context.Context, memberID int64) (domain.TeamInvitation, error) {
row, err := s.queries.GetPendingTeamInvitationByMemberID(ctx, memberID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.TeamInvitation{}, domain.ErrTeamInvitationNotFound
}
return domain.TeamInvitation{}, err
}
return mapDBTeamInvitation(row), nil
}
func (s *Store) RevokePendingTeamInvitationsForMember(ctx context.Context, memberID int64) error {
return s.queries.RevokePendingTeamInvitationsForMember(ctx, memberID)
}
func (s *Store) AcceptTeamInvitation(ctx context.Context, invitationID int64) (domain.TeamInvitation, error) {
row, err := s.queries.AcceptTeamInvitation(ctx, invitationID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.TeamInvitation{}, domain.ErrTeamInvitationNotFound
}
return domain.TeamInvitation{}, err
}
return mapDBTeamInvitation(row), nil
}
func (s *Store) RevokeTeamInvitation(ctx context.Context, invitationID int64) (domain.TeamInvitation, error) {
row, err := s.queries.RevokeTeamInvitation(ctx, invitationID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.TeamInvitation{}, domain.ErrTeamInvitationNotFound
}
return domain.TeamInvitation{}, err
}
return mapDBTeamInvitation(row), nil
}
func (s *Store) ExpireTeamInvitation(ctx context.Context, invitationID int64) error {
return s.queries.ExpireTeamInvitation(ctx, invitationID)
}
func (s *Store) ListTeamInvitations(ctx context.Context, status *string, limit, offset int32) ([]domain.TeamInvitationWithMember, int64, error) {
rows, err := s.queries.ListTeamInvitations(ctx, dbgen.ListTeamInvitationsParams{
Status: toPgText(status),
Offset: pgtype.Int4{Int32: offset, Valid: true},
Limit: pgtype.Int4{Int32: limit, Valid: true},
})
if err != nil {
return nil, 0, err
}
out := make([]domain.TeamInvitationWithMember, 0, len(rows))
var total int64
for _, row := range rows {
inv := mapDBTeamInvitation(dbgen.TeamInvitation{
ID: row.ID,
TeamMemberID: row.TeamMemberID,
Token: row.Token,
Status: row.Status,
ExpiresAt: row.ExpiresAt,
InvitedBy: row.InvitedBy,
AcceptedAt: row.AcceptedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
out = append(out, domain.TeamInvitationWithMember{
TeamInvitation: inv,
Email: row.Email,
FirstName: row.FirstName,
LastName: row.LastName,
TeamRole: domain.TeamRole(row.TeamRole),
})
total = row.TotalCount
}
return out, total, nil
}

View File

@ -0,0 +1,472 @@
package chapa
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
"Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"github.com/google/uuid"
)
var (
ErrPaymentNotFound = errors.New("payment not found")
ErrPaymentAlreadyPaid = errors.New("payment already processed")
ErrInvalidPaymentState = errors.New("invalid payment state")
ErrInvalidWebhook = errors.New("invalid webhook signature")
ErrChapaNotConfigured = errors.New("chapa is not configured")
)
type Service struct {
cfg *config.Config
httpClient *http.Client
paymentStore ports.PaymentStore
subscriptionStore ports.SubscriptionStore
userStore ports.UserStore
}
func NewService(
cfg *config.Config,
httpClient *http.Client,
paymentStore ports.PaymentStore,
subscriptionStore ports.SubscriptionStore,
userStore ports.UserStore,
) *Service {
return &Service{
cfg: cfg,
httpClient: httpClient,
paymentStore: paymentStore,
subscriptionStore: subscriptionStore,
userStore: userStore,
}
}
func (s *Service) configured() error {
if s.cfg.CHAPA_SECRET_KEY == "" {
return ErrChapaNotConfigured
}
return nil
}
// InitiateSubscriptionPayment creates a Chapa checkout session for a subscription plan.
func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) {
if err := s.configured(); err != nil {
return nil, err
}
plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, req.PlanID)
if err != nil {
return nil, fmt.Errorf("failed to get subscription plan: %w", err)
}
if !plan.IsActive {
return nil, errors.New("subscription plan is not active")
}
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to check active subscription: %w", err)
}
if hasActive {
return nil, errors.New("user already has an active subscription")
}
user, err := s.userStore.GetUserByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
firstName := strings.TrimSpace(user.FirstName)
lastName := strings.TrimSpace(user.LastName)
if firstName == "" {
firstName = "Customer"
}
if lastName == "" {
lastName = "User"
}
email := strings.TrimSpace(req.Email)
if email == "" {
email = user.Email
}
if email == "" {
return nil, errors.New("email is required for payment")
}
phone := formatChapaPhone(req.Phone)
if phone == "" && user.PhoneNumber != "" {
phone = formatChapaPhone(user.PhoneNumber)
}
txRef := uuid.NewString()
expiresAt := time.Now().Add(3 * time.Hour)
payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{
UserID: userID,
PlanID: &req.PlanID,
Amount: plan.Price,
Currency: plan.Currency,
Nonce: txRef,
ExpiresAt: &expiresAt,
})
if err != nil {
return nil, fmt.Errorf("failed to create payment record: %w", err)
}
initReq := domain.ChapaInitializeRequest{
Amount: formatAmount(plan.Price),
Currency: normalizeCurrency(plan.Currency),
Email: email,
FirstName: firstName,
LastName: lastName,
PhoneNumber: phone,
TxRef: txRef,
CallbackURL: s.cfg.CHAPA_CALLBACK_URL,
ReturnURL: s.cfg.CHAPA_RETURN_URL,
}
initReq.Customization.Title = "Yimaru LMS"
initReq.Customization.Description = fmt.Sprintf("Subscription: %s", plan.Name)
checkoutURL, err := s.initializeTransaction(ctx, initReq)
if err != nil {
return nil, err
}
if err := s.paymentStore.UpdatePaymentSessionID(ctx, payment.ID, txRef, checkoutURL); err != nil {
return nil, fmt.Errorf("failed to update payment session: %w", err)
}
return &domain.InitiateSubscriptionPaymentResponse{
PaymentID: payment.ID,
SessionID: txRef,
PaymentURL: checkoutURL,
Amount: plan.Price,
Currency: plan.Currency,
ExpiresAt: expiresAt.Format(time.RFC3339),
}, nil
}
func (s *Service) initializeTransaction(ctx context.Context, req domain.ChapaInitializeRequest) (string, error) {
payload, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("failed to marshal initialize request: %w", err)
}
url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/initialize"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(payload))
if err != nil {
return "", err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.CHAPA_SECRET_KEY)
resp, err := s.httpClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("failed to call Chapa API: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Chapa API error (status %d): %s", resp.StatusCode, string(body))
}
var result domain.ChapaInitializeResponse
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("invalid response from Chapa: %w", err)
}
if strings.ToLower(result.Status) != "success" || result.Data.CheckoutURL == "" {
return "", fmt.Errorf("Chapa initialize failed: %s", result.Message)
}
return result.Data.CheckoutURL, nil
}
// VerifyWebhookSignature validates x-chapa-signature or chapa-signature headers.
func (s *Service) VerifyWebhookSignature(body []byte, signatures ...string) error {
secret := s.cfg.CHAPA_WEBHOOK_SECRET
if secret == "" {
secret = s.cfg.CHAPA_SECRET_KEY
}
if secret == "" {
return ErrInvalidWebhook
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
for _, sig := range signatures {
sig = strings.TrimSpace(sig)
if sig != "" && hmac.Equal([]byte(expected), []byte(sig)) {
return nil
}
}
return ErrInvalidWebhook
}
// ProcessPaymentWebhook handles Chapa webhook events (charge.success, etc.).
func (s *Service) ProcessPaymentWebhook(ctx context.Context, payload domain.ChapaWebhookPayload) error {
if payload.TxRef == "" {
return errors.New("tx_ref is required")
}
// Always verify with Chapa before granting subscription access.
verifyData, err := s.fetchVerifiedTransaction(ctx, payload.TxRef)
if err != nil {
return err
}
return s.applyVerifiedTransaction(ctx, verifyData)
}
// ProcessCallback handles the redirect callback query and verifies the transaction.
func (s *Service) ProcessCallback(ctx context.Context, query domain.ChapaCallbackQuery) error {
txRef := query.TrxRef
if txRef == "" {
return errors.New("trx_ref is required")
}
verifyData, err := s.fetchVerifiedTransaction(ctx, txRef)
if err != nil {
return err
}
return s.applyVerifiedTransaction(ctx, verifyData)
}
// VerifyPayment checks payment status with Chapa using tx_ref (stored as nonce / session_id).
func (s *Service) VerifyPayment(ctx context.Context, txRef string) (*domain.Payment, error) {
if err := s.configured(); err != nil {
return nil, err
}
payment, err := s.lookupPayment(ctx, txRef)
if err != nil {
return nil, ErrPaymentNotFound
}
if payment.Status == string(domain.PaymentStatusSuccess) ||
payment.Status == string(domain.PaymentStatusFailed) {
return payment, nil
}
verifyData, err := s.fetchVerifiedTransaction(ctx, payment.Nonce)
if err != nil {
return nil, err
}
if err := s.applyVerifiedTransaction(ctx, verifyData); err != nil && !errors.Is(err, ErrPaymentAlreadyPaid) {
return nil, err
}
return s.lookupPayment(ctx, txRef)
}
func (s *Service) fetchVerifiedTransaction(ctx context.Context, txRef string) (domain.ChapaTransactionData, error) {
url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/verify/" + txRef
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return domain.ChapaTransactionData{}, err
}
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.CHAPA_SECRET_KEY)
resp, err := s.httpClient.Do(httpReq)
if err != nil {
return domain.ChapaTransactionData{}, fmt.Errorf("failed to verify with Chapa: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return domain.ChapaTransactionData{}, err
}
if resp.StatusCode != http.StatusOK {
return domain.ChapaTransactionData{}, fmt.Errorf("Chapa verify API error (status %d): %s", resp.StatusCode, string(body))
}
var result domain.ChapaVerifyResponse
if err := json.Unmarshal(body, &result); err != nil {
return domain.ChapaTransactionData{}, fmt.Errorf("failed to parse Chapa verify response: %w", err)
}
if strings.ToLower(result.Status) != "success" {
return domain.ChapaTransactionData{}, fmt.Errorf("Chapa verify failed: %s", result.Message)
}
return result.Data, nil
}
func (s *Service) applyVerifiedTransaction(ctx context.Context, data domain.ChapaTransactionData) error {
if data.TxRef == "" {
return errors.New("tx_ref missing in verified transaction")
}
payment, err := s.paymentStore.GetPaymentByNonce(ctx, data.TxRef)
if err != nil {
return fmt.Errorf("payment not found for tx_ref %s: %w", data.TxRef, err)
}
if payment.Status == string(domain.PaymentStatusSuccess) {
return ErrPaymentAlreadyPaid
}
newStatus := mapChapaStatus(data.Status)
transactionID := data.Reference
paymentMethod := data.PaymentMethod
if paymentMethod == "" {
paymentMethod = "chapa"
}
if err := s.paymentStore.UpdatePaymentStatusByNonce(
ctx,
data.TxRef,
newStatus,
transactionID,
paymentMethod,
); err != nil {
return fmt.Errorf("failed to update payment status: %w", err)
}
if newStatus != string(domain.PaymentStatusSuccess) || payment.PlanID == nil {
return nil
}
return s.activateSubscription(ctx, payment, paymentMethod)
}
func (s *Service) activateSubscription(ctx context.Context, payment *domain.Payment, paymentMethod string) error {
plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, *payment.PlanID)
if err != nil {
return fmt.Errorf("failed to get subscription plan: %w", err)
}
startsAt := time.Now()
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
activeStatus := string(domain.SubscriptionStatusActive)
autoRenew := false
paymentRef := payment.Nonce
subscription, err := s.subscriptionStore.CreateUserSubscription(ctx, domain.CreateUserSubscriptionInput{
UserID: payment.UserID,
PlanID: *payment.PlanID,
StartsAt: &startsAt,
ExpiresAt: expiresAt,
Status: &activeStatus,
PaymentReference: &paymentRef,
PaymentMethod: &paymentMethod,
AutoRenew: &autoRenew,
})
if err != nil {
return fmt.Errorf("failed to create subscription: %w", err)
}
if err := s.paymentStore.LinkPaymentToSubscription(ctx, payment.ID, subscription.ID); err != nil {
return fmt.Errorf("failed to link payment to subscription: %w", err)
}
return nil
}
func (s *Service) lookupPayment(ctx context.Context, ref string) (*domain.Payment, error) {
payment, err := s.paymentStore.GetPaymentByNonce(ctx, ref)
if err == nil {
return payment, nil
}
return s.paymentStore.GetPaymentBySessionID(ctx, ref)
}
func (s *Service) GetPaymentsByUser(ctx context.Context, userID int64, limit, offset int32) ([]domain.Payment, error) {
return s.paymentStore.GetPaymentsByUserID(ctx, userID, limit, offset)
}
func (s *Service) GetPaymentByID(ctx context.Context, id int64) (*domain.Payment, error) {
return s.paymentStore.GetPaymentByID(ctx, id)
}
func (s *Service) CancelPayment(ctx context.Context, paymentID int64, userID int64) error {
payment, err := s.paymentStore.GetPaymentByID(ctx, paymentID)
if err != nil {
return ErrPaymentNotFound
}
if payment.UserID != userID {
return errors.New("unauthorized")
}
if payment.Status != string(domain.PaymentStatusPending) {
return ErrInvalidPaymentState
}
return s.paymentStore.UpdatePaymentStatus(ctx, paymentID, string(domain.PaymentStatusCancelled))
}
func (s *Service) GetPaymentMethods() []domain.ChapaPaymentMethod {
return []domain.ChapaPaymentMethod{
{Name: "telebirr", DisplayName: "Telebirr"},
{Name: "cbebirr", DisplayName: "CBE Birr"},
{Name: "mpesa", DisplayName: "M-Pesa"},
{Name: "ebirr", DisplayName: "E-Birr"},
{Name: "amole", DisplayName: "Amole"},
{Name: "awashbirr", DisplayName: "Awash Birr"},
{Name: "enat_bank", DisplayName: "Enat Bank"},
{Name: "card", DisplayName: "Card"},
}
}
func mapChapaStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "success", "successful", "completed":
return string(domain.PaymentStatusSuccess)
case "failed", "failure":
return string(domain.PaymentStatusFailed)
case "cancelled", "canceled":
return string(domain.PaymentStatusCancelled)
case "pending", "processing":
return string(domain.PaymentStatusProcessing)
default:
return string(domain.PaymentStatusPending)
}
}
func formatAmount(amount float64) string {
return strconv.FormatFloat(math.Round(amount*100)/100, 'f', 2, 64)
}
func normalizeCurrency(currency string) string {
c := strings.TrimSpace(strings.ToUpper(currency))
if c == "" {
return "ETB"
}
return c
}
func formatChapaPhone(phone string) string {
phone = strings.TrimSpace(phone)
phone = strings.TrimPrefix(phone, "+")
if strings.HasPrefix(phone, "251") && len(phone) >= 12 {
local := phone[3:]
if strings.HasPrefix(local, "9") || strings.HasPrefix(local, "7") {
return "0" + local
}
}
if strings.HasPrefix(phone, "09") || strings.HasPrefix(phone, "07") {
return phone
}
if strings.HasPrefix(phone, "9") || strings.HasPrefix(phone, "7") {
return "0" + phone
}
return phone
}

View File

@ -0,0 +1,78 @@
package emailtemplates
// Yimaru Academy brand colors (aligned with admin portal):
// Primary #9d2a83, gradient #7b1f6e → #c43a9a, surface #eef4ff, text #333 / #666.
const (
brandEmailHeader = `<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;letter-spacing:0.3px;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">`
brandEmailFooter = `</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;line-height:1.5;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>`
brandButtonPrimary = `display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;line-height:1.2;`
brandHeadingStyle = `margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;line-height:1.3;`
brandBodyStyle = `margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;`
brandMutedStyle = `margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;`
brandAccentBox = `background-color:#eef4ff;border-radius:8px;padding:20px;border:1px solid #e0e8f5;text-align:center;`
)
const (
defaultOTPSubject = "Yimaru Academy — Your verification code"
defaultOTPText = "Yimaru Academy{{if .FirstName}}, {{.FirstName}}{{end}}\n\nYour verification code is {{.OTP}}.\nIt expires in {{.ExpiresMinutes}} minutes.\n\nPlease do not share this code with anyone."
defaultOTPHTML = brandEmailHeader + `
<h1 style="` + brandHeadingStyle + `">Your verification code</h1>
<p style="` + brandBodyStyle + `">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}use the code below to continue signing in to Yimaru Academy.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td style="` + brandAccentBox + `">
<p style="margin:0 0 6px;color:#9d2a83;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;">One-time password</p>
<p style="margin:0;color:#333333;font-size:34px;font-weight:700;letter-spacing:8px;font-family:Consolas,Monaco,monospace;">{{.OTP}}</p>
<p style="margin:12px 0 0;color:#666666;font-size:13px;">Expires in {{.ExpiresMinutes}} minutes</p>
</td></tr></table>
<p style="` + brandMutedStyle + `">If you did not request this code, you can safely ignore this email.</p>
` + brandEmailFooter
defaultInvitationSubject = "You are invited to Yimaru Academy"
defaultInvitationText = "Hi{{if .FirstName}} {{.FirstName}}{{end}},\n\nYou have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Academy.\n\nAccept your invitation: {{.InviteLink}}"
defaultInvitationHTML = brandEmailHeader + `
<h1 style="` + brandHeadingStyle + `">You&rsquo;re invited</h1>
<p style="` + brandBodyStyle + `">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}you have been invited{{if .InviterName}} by <strong style="color:#9d2a83;">{{.InviterName}}</strong>{{end}} to join Yimaru Academy.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.InviteLink}}" style="` + brandButtonPrimary + `">Accept invitation</a></p>
<p style="` + brandMutedStyle + `">Or copy this link: <a href="{{.InviteLink}}" style="color:#9d2a83;">{{.InviteLink}}</a></p>
` + brandEmailFooter
defaultPasswordResetSubject = "Reset your Yimaru Academy password"
defaultPasswordResetText = "Hi{{if .FirstName}} {{.FirstName}}{{end}},\n\nReset your password: {{.ResetLink}}\n\nThis link expires in {{.ExpiresMinutes}} minutes."
defaultPasswordResetHTML = brandEmailHeader + `
<h1 style="` + brandHeadingStyle + `">Reset your password</h1>
<p style="` + brandBodyStyle + `">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}we received a request to reset your Yimaru Academy password. The link below expires in {{.ExpiresMinutes}} minutes.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.ResetLink}}" style="` + brandButtonPrimary + `">Reset password</a></p>
<p style="` + brandMutedStyle + `">If you did not request a reset, ignore this email.</p>
` + brandEmailFooter
defaultWelcomeSubject = "Welcome to Yimaru Academy"
defaultWelcomeText = "Hi{{if .FirstName}} {{.FirstName}}{{end}},\n\nWelcome to Yimaru Academy! Sign in to get started: {{.LoginURL}}"
defaultWelcomeHTML = brandEmailHeader + `
<h1 style="` + brandHeadingStyle + `">Welcome aboard</h1>
<p style="` + brandBodyStyle + `">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}your Yimaru Academy account is ready. Start learning at your own pace.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.LoginURL}}" style="` + brandButtonPrimary + `">Sign in to Yimaru Academy</a></p>
` + brandEmailFooter
defaultCustomMessageHTML = brandEmailHeader + `
<h1 style="` + brandHeadingStyle + `">{{.Subject}}</h1>
<div style="` + brandBodyStyle + `margin-bottom:0;">{{.Message}}</div>
` + brandEmailFooter
)

View File

@ -0,0 +1,112 @@
package emailtemplates
import (
"Yimaru-Backend/internal/domain"
"bytes"
)
var defaultTemplates = map[string]domain.EmailTemplate{
domain.EmailTemplateSlugOTP: {
Slug: domain.EmailTemplateSlugOTP,
Name: "One-Time Password",
Subject: defaultOTPSubject,
BodyText: defaultOTPText,
BodyHTML: defaultOTPHTML,
Variables: []string{"OTP", "FirstName", "ExpiresMinutes"},
Status: domain.EmailTemplateStatusActive,
},
domain.EmailTemplateSlugInvitation: {
Slug: domain.EmailTemplateSlugInvitation,
Name: "User Invitation",
Subject: defaultInvitationSubject,
BodyText: defaultInvitationText,
BodyHTML: defaultInvitationHTML,
Variables: []string{"FirstName", "InviterName", "InviteLink"},
Status: domain.EmailTemplateStatusActive,
},
domain.EmailTemplateSlugPasswordReset: {
Slug: domain.EmailTemplateSlugPasswordReset,
Name: "Password Reset",
Subject: defaultPasswordResetSubject,
BodyText: defaultPasswordResetText,
BodyHTML: defaultPasswordResetHTML,
Variables: []string{"FirstName", "ResetLink", "ExpiresMinutes"},
Status: domain.EmailTemplateStatusActive,
},
domain.EmailTemplateSlugWelcome: {
Slug: domain.EmailTemplateSlugWelcome,
Name: "Welcome Email",
Subject: defaultWelcomeSubject,
BodyText: defaultWelcomeText,
BodyHTML: defaultWelcomeHTML,
Variables: []string{"FirstName", "LoginURL"},
Status: domain.EmailTemplateStatusActive,
},
domain.EmailTemplateSlugCustomMessage: {
Slug: domain.EmailTemplateSlugCustomMessage,
Name: "Custom Message",
Subject: "{{.Subject}}",
BodyText: "{{.Message}}",
BodyHTML: defaultCustomMessageHTML,
Variables: []string{"Subject", "Message"},
Status: domain.EmailTemplateStatusActive,
},
}
func defaultTemplate(slug string) (domain.EmailTemplate, bool) {
tmpl, ok := defaultTemplates[slug]
return tmpl, ok
}
func renderTemplateFields(tmpl domain.EmailTemplate, data map[string]any) (domain.RenderedEmail, error) {
if data == nil {
data = map[string]any{}
}
subject, err := executeTextTemplate("subject:"+tmpl.Slug, tmpl.Subject, data)
if err != nil {
return domain.RenderedEmail{}, err
}
text, err := executeTextTemplate("text:"+tmpl.Slug, tmpl.BodyText, data)
if err != nil {
return domain.RenderedEmail{}, err
}
html, err := executeHTMLTemplate("html:"+tmpl.Slug, tmpl.BodyHTML, data)
if err != nil {
return domain.RenderedEmail{}, err
}
return domain.RenderedEmail{
Subject: subject,
Text: text,
HTML: html,
}, nil
}
func executeTextTemplate(name, content string, data map[string]any) (string, error) {
tmpl, err := newTextTemplate(name, content)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func executeHTMLTemplate(name, content string, data map[string]any) (string, error) {
tmpl, err := newHTMLTemplate(name, content)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}

View File

@ -0,0 +1,318 @@
package emailtemplates
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
)
var (
errSlugRequired = errors.New("slug is required")
errInvalidSlug = errors.New("slug must start with a letter and contain only lowercase letters, numbers, and underscores")
errInvalidStatus = fmt.Errorf("status must be one of %s, %s", domain.EmailTemplateStatusActive, domain.EmailTemplateStatusInactive)
)
const cacheTTL = 2 * time.Minute
type cacheEntry struct {
template domain.EmailTemplate
expiresAt time.Time
}
type Service struct {
store ports.EmailTemplateStore
mu sync.RWMutex
cache map[string]cacheEntry
}
func NewService(store ports.EmailTemplateStore) *Service {
return &Service{
store: store,
cache: make(map[string]cacheEntry),
}
}
func (s *Service) CreateEmailTemplate(ctx context.Context, input domain.CreateEmailTemplateInput) (domain.EmailTemplate, error) {
slug, err := normalizeSlug(input.Slug)
if err != nil {
return domain.EmailTemplate{}, err
}
input.Slug = slug
input.Name = strings.TrimSpace(input.Name)
input.Subject = strings.TrimSpace(input.Subject)
input.BodyText = strings.TrimSpace(input.BodyText)
input.BodyHTML = strings.TrimSpace(input.BodyHTML)
input.Variables = normalizeVariables(input.Variables)
if input.Name == "" {
return domain.EmailTemplate{}, fmt.Errorf("name is required")
}
if input.Subject == "" {
return domain.EmailTemplate{}, fmt.Errorf("subject is required")
}
if input.BodyText == "" {
return domain.EmailTemplate{}, fmt.Errorf("body_text is required")
}
if input.BodyHTML == "" {
return domain.EmailTemplate{}, fmt.Errorf("body_html is required")
}
status, err := normalizeStatus(input.Status)
if err != nil {
return domain.EmailTemplate{}, err
}
input.Status = &status
if err := s.validateTemplateSyntax(input.Subject, input.BodyText, input.BodyHTML); err != nil {
return domain.EmailTemplate{}, err
}
tmpl, err := s.store.CreateEmailTemplate(ctx, input)
if err != nil {
return domain.EmailTemplate{}, err
}
s.invalidateCache(tmpl.Slug)
return tmpl, nil
}
func (s *Service) UpdateEmailTemplate(ctx context.Context, id int64, input domain.UpdateEmailTemplateInput) (domain.EmailTemplate, error) {
if id <= 0 {
return domain.EmailTemplate{}, fmt.Errorf("invalid email template id")
}
if input.Name != nil {
trimmed := strings.TrimSpace(*input.Name)
if trimmed == "" {
return domain.EmailTemplate{}, fmt.Errorf("name cannot be empty")
}
input.Name = &trimmed
}
if input.Subject != nil {
trimmed := strings.TrimSpace(*input.Subject)
if trimmed == "" {
return domain.EmailTemplate{}, fmt.Errorf("subject cannot be empty")
}
input.Subject = &trimmed
}
if input.BodyText != nil {
trimmed := strings.TrimSpace(*input.BodyText)
if trimmed == "" {
return domain.EmailTemplate{}, fmt.Errorf("body_text cannot be empty")
}
input.BodyText = &trimmed
}
if input.BodyHTML != nil {
trimmed := strings.TrimSpace(*input.BodyHTML)
if trimmed == "" {
return domain.EmailTemplate{}, fmt.Errorf("body_html cannot be empty")
}
input.BodyHTML = &trimmed
}
if input.Variables != nil {
input.Variables = normalizeVariables(input.Variables)
}
if input.Status != nil {
status, err := normalizeStatus(input.Status)
if err != nil {
return domain.EmailTemplate{}, err
}
input.Status = &status
}
current, err := s.store.GetEmailTemplateByID(ctx, id, true)
if err != nil {
return domain.EmailTemplate{}, err
}
subject := current.Subject
if input.Subject != nil {
subject = *input.Subject
}
bodyText := current.BodyText
if input.BodyText != nil {
bodyText = *input.BodyText
}
bodyHTML := current.BodyHTML
if input.BodyHTML != nil {
bodyHTML = *input.BodyHTML
}
if err := s.validateTemplateSyntax(subject, bodyText, bodyHTML); err != nil {
return domain.EmailTemplate{}, err
}
tmpl, err := s.store.UpdateEmailTemplate(ctx, id, input)
if err != nil {
return domain.EmailTemplate{}, err
}
s.invalidateCache(tmpl.Slug)
return tmpl, nil
}
func (s *Service) GetEmailTemplateByID(ctx context.Context, id int64, includeInactive bool) (domain.EmailTemplate, error) {
if id <= 0 {
return domain.EmailTemplate{}, fmt.Errorf("invalid email template id")
}
return s.store.GetEmailTemplateByID(ctx, id, includeInactive)
}
func (s *Service) GetEmailTemplateBySlug(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) {
normalized, err := normalizeSlug(slug)
if err != nil {
return domain.EmailTemplate{}, err
}
return s.store.GetEmailTemplateBySlug(ctx, normalized, includeInactive)
}
func (s *Service) ListEmailTemplates(ctx context.Context, status *string, query *string, limit int32, offset int32) ([]domain.EmailTemplate, int64, error) {
if status != nil {
normalized, err := normalizeStatus(status)
if err != nil {
return nil, 0, err
}
status = &normalized
}
if query != nil {
trimmed := strings.TrimSpace(*query)
if trimmed == "" {
query = nil
} else {
query = &trimmed
}
}
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.store.ListEmailTemplates(ctx, status, query, limit, offset)
}
func (s *Service) DeleteEmailTemplate(ctx context.Context, id int64) error {
if id <= 0 {
return fmt.Errorf("invalid email template id")
}
tmpl, err := s.store.GetEmailTemplateByID(ctx, id, true)
if err != nil {
return err
}
if tmpl.IsSystem {
return fmt.Errorf("system email templates cannot be deleted")
}
if err := s.store.DeleteEmailTemplate(ctx, id); err != nil {
return err
}
s.invalidateCache(tmpl.Slug)
return nil
}
func (s *Service) PreviewEmailTemplate(ctx context.Context, input domain.PreviewEmailTemplateInput) (domain.RenderedEmail, error) {
slug, err := normalizeSlug(input.Slug)
if err != nil {
return domain.RenderedEmail{}, err
}
tmpl, err := s.resolveTemplate(ctx, slug, true)
if err != nil {
return domain.RenderedEmail{}, err
}
return renderTemplateFields(tmpl, input.Variables)
}
func (s *Service) Render(ctx context.Context, slug string, data map[string]any) (domain.RenderedEmail, error) {
normalized, err := normalizeSlug(slug)
if err != nil {
return domain.RenderedEmail{}, err
}
tmpl, err := s.resolveTemplate(ctx, normalized, false)
if err != nil {
return domain.RenderedEmail{}, err
}
return renderTemplateFields(tmpl, data)
}
func (s *Service) resolveTemplate(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) {
if !includeInactive {
if tmpl, ok := s.getCached(slug); ok {
return tmpl, nil
}
}
tmpl, err := s.store.GetEmailTemplateBySlug(ctx, slug, includeInactive)
if err == nil {
if !includeInactive && tmpl.Status == domain.EmailTemplateStatusActive {
s.setCached(slug, tmpl)
}
return tmpl, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.EmailTemplate{}, err
}
fallback, ok := defaultTemplate(slug)
if !ok {
return domain.EmailTemplate{}, fmt.Errorf("email template not found: %s", slug)
}
return fallback, nil
}
func (s *Service) validateTemplateSyntax(subject, bodyText, bodyHTML string) error {
if _, err := newTextTemplate("validate-subject", subject); err != nil {
return fmt.Errorf("invalid subject template: %w", err)
}
if _, err := newTextTemplate("validate-text", bodyText); err != nil {
return fmt.Errorf("invalid body_text template: %w", err)
}
if _, err := newHTMLTemplate("validate-html", bodyHTML); err != nil {
return fmt.Errorf("invalid body_html template: %w", err)
}
return nil
}
func (s *Service) getCached(slug string) (domain.EmailTemplate, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
entry, ok := s.cache[slug]
if !ok || time.Now().After(entry.expiresAt) {
return domain.EmailTemplate{}, false
}
return entry.template, true
}
func (s *Service) setCached(slug string, tmpl domain.EmailTemplate) {
s.mu.Lock()
defer s.mu.Unlock()
s.cache[slug] = cacheEntry{
template: tmpl,
expiresAt: time.Now().Add(cacheTTL),
}
}
func (s *Service) invalidateCache(slug string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.cache, slug)
}

View File

@ -0,0 +1,63 @@
package emailtemplates
import (
"Yimaru-Backend/internal/domain"
"html/template"
"regexp"
"strings"
texttemplate "text/template"
)
var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
func normalizeSlug(slug string) (string, error) {
value := strings.ToLower(strings.TrimSpace(slug))
if value == "" {
return "", errSlugRequired
}
if !slugPattern.MatchString(value) {
return "", errInvalidSlug
}
return value, nil
}
func normalizeStatus(status *string) (string, error) {
if status == nil || strings.TrimSpace(*status) == "" {
return domain.EmailTemplateStatusActive, nil
}
value := strings.ToUpper(strings.TrimSpace(*status))
switch value {
case domain.EmailTemplateStatusActive, domain.EmailTemplateStatusInactive:
return value, nil
default:
return "", errInvalidStatus
}
}
func normalizeVariables(vars []string) []string {
if len(vars) == 0 {
return []string{}
}
out := make([]string, 0, len(vars))
seen := make(map[string]struct{}, len(vars))
for _, variable := range vars {
trimmed := strings.TrimSpace(variable)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}
func newTextTemplate(name, content string) (*texttemplate.Template, error) {
return texttemplate.New(name).Parse(content)
}
func newHTMLTemplate(name, content string) (*template.Template, error) {
return template.New(name).Parse(content)
}

View File

@ -15,13 +15,14 @@ var ErrModuleNotFound = errors.New("exam prep module not found")
var ErrLessonNotFound = errors.New("exam prep lesson not found") var ErrLessonNotFound = errors.New("exam prep lesson not found")
var ErrPracticeNotFound = errors.New("exam prep practice not found") var ErrPracticeNotFound = errors.New("exam prep practice not found")
// examPrepStore is implemented by *repository.Store (catalog courses, units, modules, lessons, practices). // examPrepStore is implemented by *repository.Store (catalog courses, units, modules, lessons, practices, personas).
type examPrepStore interface { type examPrepStore interface {
ports.ExamPrepCatalogCourseStore ports.ExamPrepCatalogCourseStore
ports.ExamPrepUnitStore ports.ExamPrepUnitStore
ports.ExamPrepModuleStore ports.ExamPrepModuleStore
ports.ExamPrepLessonStore ports.ExamPrepLessonStore
ports.ExamPrepPracticeStore ports.ExamPrepPracticeStore
ports.LmsPersonaReader
} }
type Service struct { type Service struct {
@ -32,6 +33,17 @@ func NewService(store examPrepStore) *Service {
return &Service{store: store} return &Service{store: store}
} }
func (s *Service) ensurePersonaRef(ctx context.Context, id int64) error {
if id <= 0 {
return domain.ErrPersonaNotFound
}
_, err := s.store.GetLmsPersonaByID(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrPersonaNotFound
}
return err
}
func (s *Service) CreateCatalogCourse(ctx context.Context, input domain.CreateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) { func (s *Service) CreateCatalogCourse(ctx context.Context, input domain.CreateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) {
return s.store.CreateExamPrepCatalogCourse(ctx, input) return s.store.CreateExamPrepCatalogCourse(ctx, input)
} }
@ -355,6 +367,11 @@ func (s *Service) CreateExamPrepPractice(ctx context.Context, lessonID int64, in
if err := s.ensureLesson(ctx, lessonID); err != nil { if err := s.ensureLesson(ctx, lessonID); err != nil {
return domain.ExamPrepPractice{}, err return domain.ExamPrepPractice{}, err
} }
if input.PersonaID != nil {
if err := s.ensurePersonaRef(ctx, *input.PersonaID); err != nil {
return domain.ExamPrepPractice{}, err
}
}
return s.store.CreateExamPrepLessonPractice(ctx, lessonID, input) return s.store.CreateExamPrepLessonPractice(ctx, lessonID, input)
} }
@ -390,6 +407,11 @@ func (s *Service) TryGetExamPrepPracticeByQuestionSetID(ctx context.Context, que
} }
func (s *Service) UpdateExamPrepPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) { func (s *Service) UpdateExamPrepPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
if input.PersonaID != nil {
if err := s.ensurePersonaRef(ctx, *input.PersonaID); err != nil {
return domain.ExamPrepPractice{}, err
}
}
p, err := s.store.UpdateExamPrepLessonPractice(ctx, id, input) p, err := s.store.UpdateExamPrepLessonPractice(ctx, id, input)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View File

@ -50,7 +50,7 @@ func (s *Service) GetByID(ctx context.Context, id int64) (domain.Lesson, error)
return l, nil return l, nil
} }
func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) { func (s *Service) ListByModule(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Lesson, int64, error) {
if err := s.getModuleOrErr(ctx, moduleID); err != nil { if err := s.getModuleOrErr(ctx, moduleID); err != nil {
return nil, 0, err return nil, 0, err
} }
@ -63,7 +63,7 @@ func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offse
if offset < 0 { if offset < 0 {
offset = 0 offset = 0
} }
return s.lessons.ListLessonsByModuleID(ctx, moduleID, limit, offset) return s.lessons.ListLessonsByModuleID(ctx, moduleID, publishedOnly, limit, offset)
} }
func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) { func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) {

View File

@ -0,0 +1,95 @@
package notificationservice
import (
"encoding/json"
"strconv"
"strings"
)
// normalizeFCMCredentialsJSON returns credential bytes valid for encoding/json and
// Firebase's credentials parser. Repairs the common mistake of pasting PEM with
// literal newlines inside the JSON "private_key" string (.env multiline breakage).
func normalizeFCMCredentialsJSON(raw string) ([]byte, error) {
s := strings.TrimSpace(strings.TrimPrefix(raw, "\ufeff"))
if json.Valid([]byte(s)) {
return []byte(s), nil
}
if fixed := tryRepairFirebasePrivateKeyNewlines(s); fixed != "" && fixed != s {
b := []byte(fixed)
if json.Valid(b) {
return b, nil
}
}
var m map[string]any
err := json.Unmarshal([]byte(strings.TrimSpace(s)), &m)
return nil, err
}
// tryRepairFirebasePrivateKeyNewlines rewrites `"private_key": "...(PEM with real newlines)..."`
// using strconv.Quote for the inner PEM. Empty string means no rewrite applied.
func tryRepairFirebasePrivateKeyNewlines(s string) string {
const beginRSA = "-----BEGIN RSA PRIVATE KEY-----"
const endRSA = "-----END RSA PRIVATE KEY-----"
const beginPK = "-----BEGIN PRIVATE KEY-----"
const endPK = "-----END PRIVATE KEY-----"
beginIdx := strings.Index(s, beginRSA)
endMarker := endRSA
endIdx := strings.Index(s, endMarker)
if beginIdx < 0 {
beginIdx = strings.Index(s, beginPK)
endMarker = endPK
endIdx = strings.Index(s, endMarker)
}
if beginIdx < 0 || endIdx < beginIdx {
return ""
}
endInclusive := endIdx + len(endMarker)
const pkAttr = `"private_key"`
pkIdx := strings.Index(s, pkAttr)
if pkIdx < 0 || pkIdx > beginIdx {
return ""
}
restAfterKey := s[pkIdx+len(pkAttr):]
colonRel := strings.Index(restAfterKey, ":")
if colonRel < 0 {
return ""
}
searchFrom := pkIdx + len(pkAttr) + colonRel + 1
openQuote := -1
for i := searchFrom; i < beginIdx; i++ {
c := s[i]
if c == '"' {
openQuote = i
break
}
if c != ' ' && c != '\t' && c != '\n' && c != '\r' {
return ""
}
}
if openQuote < 0 {
return ""
}
closeQuote := -1
for i := endInclusive; i < len(s); i++ {
c := s[i]
if c == '"' {
closeQuote = i
break
}
if c != ' ' && c != '\t' && c != '\n' && c != '\r' {
return ""
}
}
if closeQuote < 0 || closeQuote <= openQuote {
return ""
}
inner := s[openQuote+1 : closeQuote]
quoted := strconv.Quote(inner)
return s[:openQuote] + quoted + s[closeQuote+1:]
}

View File

@ -0,0 +1,40 @@
package notificationservice
import (
"encoding/json"
"strings"
"testing"
)
func TestNormalizeFCMCredentialsJSON_multilinePrivateKey(t *testing.T) {
raw := `{` +
`"type":"service_account",` +
`"project_id":"my-proj",` +
`"private_key":"-----BEGIN PRIVATE KEY-----` +
`\nLINETWO\n` +
`-----END PRIVATE KEY-----\n",` +
`"client_email":"x@some.iam.gserviceaccount.com"` +
`}`
raw = strings.ReplaceAll(raw, "\\n", "\n")
var broken map[string]any
if err := json.Unmarshal([]byte(raw), &broken); err == nil {
t.Fatal("expected broken JSON fixture to fail raw parse")
}
fixed, err := normalizeFCMCredentialsJSON(raw)
if err != nil {
t.Fatalf("normalize: %v", err)
}
var m map[string]any
if err := json.Unmarshal(fixed, &m); err != nil {
t.Fatalf("unmarshal normalized: %v", err)
}
if got := m["project_id"]; got != "my-proj" {
t.Fatalf("project_id: %v", got)
}
pk, _ := m["private_key"].(string)
if !strings.Contains(pk, "LINETWO") {
t.Fatalf("private_key body missing")
}
}

View File

@ -69,8 +69,9 @@ func New(
config: cfg, config: cfg,
} }
mongoLogger.Info("FCM_SERVICE_ACCOUNT_KEY value at startup", mongoLogger.Info("FCM credentials config at startup",
zap.String("fcm_service_account_key", cfg.FCMServiceAccountKey), zap.Bool("fcm_credentials_configured", strings.TrimSpace(cfg.FCMServiceAccountKey) != ""),
zap.Int("fcm_service_account_json_len", len(strings.TrimSpace(cfg.FCMServiceAccountKey))),
) )
// Initialize FCM client if service account key is provided // Initialize FCM client if service account key is provided
@ -97,11 +98,15 @@ func (s *Service) initFCMClient() error {
var opts []option.ClientOption var opts []option.ClientOption
var fbConfig *firebase.Config var fbConfig *firebase.Config
if s.config.FCMServiceAccountKey != "" { if s.config.FCMServiceAccountKey != "" {
credJSON, errNorm := normalizeFCMCredentialsJSON(s.config.FCMServiceAccountKey)
if errNorm != nil {
return fmt.Errorf("invalid FCM service account JSON: %w (hint: use valid JSON — minify with jq -c, set FCM_SERVICE_ACCOUNT_KEY_FILE, or ensure PEM in private_key has no raw line breaks inside the JSON string)", errNorm)
}
var sa struct { var sa struct {
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
} }
if err := json.Unmarshal([]byte(s.config.FCMServiceAccountKey), &sa); err != nil { if err := json.Unmarshal(credJSON, &sa); err != nil {
return fmt.Errorf("invalid FCM_SERVICE_ACCOUNT_KEY JSON: %w", err) return fmt.Errorf("invalid FCM service account JSON: %w", err)
} }
if strings.TrimSpace(sa.ProjectID) == "" { if strings.TrimSpace(sa.ProjectID) == "" {
return fmt.Errorf("FCM_SERVICE_ACCOUNT_KEY is missing project_id") return fmt.Errorf("FCM_SERVICE_ACCOUNT_KEY is missing project_id")
@ -109,7 +114,7 @@ func (s *Service) initFCMClient() error {
fbConfig = &firebase.Config{ fbConfig = &firebase.Config{
ProjectID: strings.TrimSpace(sa.ProjectID), ProjectID: strings.TrimSpace(sa.ProjectID),
} }
opts = append(opts, option.WithCredentialsJSON([]byte(s.config.FCMServiceAccountKey))) opts = append(opts, option.WithCredentialsJSON(credJSON))
} }
// Initialize Firebase app // Initialize Firebase app

View File

@ -0,0 +1,103 @@
package personas
import (
"context"
"errors"
"strings"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"github.com/jackc/pgx/v5"
)
var ErrPersonaNotFound = domain.ErrPersonaNotFound
// ErrNameRequired indicates a missing trim-empty name on create.
var ErrNameRequired = errors.New("name is required")
// ErrNameEmptyUpdate indicates an update attempted to clear the persona name.
var ErrNameEmptyUpdate = errors.New("name cannot be empty")
type Service struct {
store ports.LmsPersonaStore
}
func NewService(store ports.LmsPersonaStore) *Service {
return &Service{store: store}
}
func clampPage(limit, offset int32) (int32, int32) {
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return limit, offset
}
func (s *Service) Create(ctx context.Context, in domain.CreateLmsPersonaInput) (domain.LmsPersona, error) {
name := strings.TrimSpace(in.Name)
if name == "" {
return domain.LmsPersona{}, ErrNameRequired
}
in.Name = name
if in.Gender != nil {
t := strings.TrimSpace(*in.Gender)
if t == "" {
in.Gender = nil
} else {
in.Gender = &t
}
}
return s.store.CreateLmsPersona(ctx, in)
}
func (s *Service) GetByID(ctx context.Context, id int64) (domain.LmsPersona, error) {
p, err := s.store.GetLmsPersonaByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.LmsPersona{}, ErrPersonaNotFound
}
return domain.LmsPersona{}, err
}
return p, nil
}
func (s *Service) Update(ctx context.Context, id int64, in domain.UpdateLmsPersonaInput) (domain.LmsPersona, error) {
if in.Name != nil {
t := strings.TrimSpace(*in.Name)
if t == "" {
return domain.LmsPersona{}, ErrNameEmptyUpdate
}
in.Name = &t
}
if in.Gender != nil {
t := strings.TrimSpace(*in.Gender)
in.Gender = &t // empty string clears stored gender when client sends gender: ""
}
p, err := s.store.UpdateLmsPersona(ctx, id, in)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.LmsPersona{}, ErrPersonaNotFound
}
return domain.LmsPersona{}, err
}
return p, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
if err := s.store.DeleteLmsPersona(ctx, id); err != nil {
return err
}
return nil
}
func (s *Service) List(ctx context.Context, activeOnly bool, limit, offset int32) ([]domain.LmsPersona, int64, error) {
limit, offset = clampPage(limit, offset)
return s.store.ListLmsPersonas(ctx, activeOnly, limit, offset)
}

View File

@ -24,7 +24,7 @@ type Service struct {
modules ports.ModuleStore modules ports.ModuleStore
lessons ports.LessonStore lessons ports.LessonStore
qs ports.QuestionSetByID qs ports.QuestionSetByID
users ports.UserByID personas ports.LmsPersonaReader
} }
func NewService( func NewService(
@ -33,7 +33,7 @@ func NewService(
modules ports.ModuleStore, modules ports.ModuleStore,
lessons ports.LessonStore, lessons ports.LessonStore,
qs ports.QuestionSetByID, qs ports.QuestionSetByID,
users ports.UserByID, personas ports.LmsPersonaReader,
) *Service { ) *Service {
return &Service{ return &Service{
practices: practices, practices: practices,
@ -41,7 +41,7 @@ func NewService(
modules: modules, modules: modules,
lessons: lessons, lessons: lessons,
qs: qs, qs: qs,
users: users, personas: personas,
} }
} }
@ -56,17 +56,19 @@ func (s *Service) validateQuestionSet(ctx context.Context, id int64) error {
return nil return nil
} }
func (s *Service) validatePersonaUser(ctx context.Context, id int64) error { func (s *Service) validatePersonaCatalog(ctx context.Context, id int64) error {
_, err := s.users.GetUserByID(ctx, id) if id <= 0 {
return domain.ErrPersonaNotFound
}
_, err := s.personas.GetLmsPersonaByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, domain.ErrUserNotFound) { if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrUserNotFound return domain.ErrPersonaNotFound
} }
return err return err
} }
return nil return nil
} }
func (s *Service) resolveParent(ctx context.Context, in domain.CreatePracticeInput) (courseID, moduleID, lessonID *int64, err error) { func (s *Service) resolveParent(ctx context.Context, in domain.CreatePracticeInput) (courseID, moduleID, lessonID *int64, err error) {
pid := in.ParentID pid := in.ParentID
switch in.ParentKind { switch in.ParentKind {
@ -104,7 +106,7 @@ func (s *Service) Create(ctx context.Context, in domain.CreatePracticeInput) (do
return domain.Practice{}, err return domain.Practice{}, err
} }
if in.PersonaID != nil { if in.PersonaID != nil {
if err := s.validatePersonaUser(ctx, *in.PersonaID); err != nil { if err := s.validatePersonaCatalog(ctx, *in.PersonaID); err != nil {
return domain.Practice{}, err return domain.Practice{}, err
} }
} }
@ -183,7 +185,7 @@ func (s *Service) Update(ctx context.Context, id int64, input domain.UpdatePract
} }
} }
if input.PersonaID != nil { if input.PersonaID != nil {
if err := s.validatePersonaUser(ctx, *input.PersonaID); err != nil { if err := s.validatePersonaCatalog(ctx, *input.PersonaID); err != nil {
return domain.Practice{}, err return domain.Practice{}, err
} }
} }

View File

@ -86,6 +86,13 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "practices.update", Name: "Update Practice", Description: "Update a practice", GroupName: "Practices"}, {Key: "practices.update", Name: "Update Practice", Description: "Update a practice", GroupName: "Practices"},
{Key: "practices.delete", Name: "Delete Practice", Description: "Delete a practice", GroupName: "Practices"}, {Key: "practices.delete", Name: "Delete Practice", Description: "Delete a practice", GroupName: "Practices"},
// LMS personas (catalog for coach/character profiles linked on practices)
{Key: "personas.create", Name: "Create Persona", Description: "Create an LMS persona profile", GroupName: "Personas"},
{Key: "personas.list", Name: "List Personas", Description: "List LMS persona profiles", GroupName: "Personas"},
{Key: "personas.get", Name: "Get Persona", Description: "Get an LMS persona by ID", GroupName: "Personas"},
{Key: "personas.update", Name: "Update Persona", Description: "Update an LMS persona", GroupName: "Personas"},
{Key: "personas.delete", Name: "Delete Persona", Description: "Delete an LMS persona", GroupName: "Personas"},
// Course Management - Sub-courses // Course Management - Sub-courses
{Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"}, {Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"},
{Key: "subcourses.get", Name: "Get Sub-course", Description: "Get a sub-course by ID", GroupName: "Sub-courses"}, {Key: "subcourses.get", Name: "Get Sub-course", Description: "Get a sub-course by ID", GroupName: "Sub-courses"},
@ -239,6 +246,14 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "faqs.update", Name: "Update FAQ", Description: "Update a FAQ item", GroupName: "FAQs"}, {Key: "faqs.update", Name: "Update FAQ", Description: "Update a FAQ item", GroupName: "FAQs"},
{Key: "faqs.delete", Name: "Delete FAQ", Description: "Delete a FAQ item", GroupName: "FAQs"}, {Key: "faqs.delete", Name: "Delete FAQ", Description: "Delete a FAQ item", GroupName: "FAQs"},
// Email templates
{Key: "email_templates.create", Name: "Create Email Template", Description: "Create an email template", GroupName: "Email Templates"},
{Key: "email_templates.list", Name: "List Email Templates", Description: "List email templates for admin management", GroupName: "Email Templates"},
{Key: "email_templates.get", Name: "Get Email Template", Description: "Get an email template by ID or slug", GroupName: "Email Templates"},
{Key: "email_templates.update", Name: "Update Email Template", Description: "Update an email template", GroupName: "Email Templates"},
{Key: "email_templates.delete", Name: "Delete Email Template", Description: "Delete a custom email template", GroupName: "Email Templates"},
{Key: "email_templates.preview", Name: "Preview Email Template", Description: "Preview a rendered email template", GroupName: "Email Templates"},
// Analytics // Analytics
{Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"}, {Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"},
@ -261,6 +276,10 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "team.members.update_status", Name: "Update Team Member Status", Description: "Update team member status", GroupName: "Team"}, {Key: "team.members.update_status", Name: "Update Team Member Status", Description: "Update team member status", GroupName: "Team"},
{Key: "team.members.delete", Name: "Delete Team Member", Description: "Delete a team member", GroupName: "Team"}, {Key: "team.members.delete", Name: "Delete Team Member", Description: "Delete a team member", GroupName: "Team"},
{Key: "team.members.change_password", Name: "Change Team Password", Description: "Change team member password", GroupName: "Team"}, {Key: "team.members.change_password", Name: "Change Team Password", Description: "Change team member password", GroupName: "Team"},
{Key: "team.members.invite", Name: "Invite Team Member", Description: "Send email invitation for a new team member", GroupName: "Team"},
{Key: "team.invitations.list", Name: "List Team Invitations", Description: "List team member invitations", GroupName: "Team"},
{Key: "team.invitations.resend", Name: "Resend Team Invitation", Description: "Resend a pending team invitation email", GroupName: "Team"},
{Key: "team.invitations.revoke", Name: "Revoke Team Invitation", Description: "Revoke a pending team invitation", GroupName: "Team"},
// Sub-course Prerequisites // Sub-course Prerequisites
{Key: "subcourse_prerequisites.add", Name: "Add Prerequisite", Description: "Add sub-course prerequisite", GroupName: "Sub-course Prerequisites"}, {Key: "subcourse_prerequisites.add", Name: "Add Prerequisite", Description: "Add sub-course prerequisite", GroupName: "Sub-course Prerequisites"},
@ -396,6 +415,9 @@ var DefaultRolePermissions = map[string][]string{
// Practices // Practices
"practices.create", "practices.get", "practices.list", "practices.update", "practices.delete", "practices.create", "practices.get", "practices.list", "practices.update", "practices.delete",
// LMS personas catalog
"personas.create", "personas.list", "personas.get", "personas.update", "personas.delete",
// Questions (full access) // Questions (full access)
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete", "questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete",
"question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete", "question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete",
@ -438,6 +460,9 @@ var DefaultRolePermissions = map[string][]string{
// FAQs // FAQs
"faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete", "faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete",
// Email templates
"email_templates.create", "email_templates.list", "email_templates.get", "email_templates.update", "email_templates.delete", "email_templates.preview",
// Analytics (previously OnlyAdminAndAbove) // Analytics (previously OnlyAdminAndAbove)
"analytics.dashboard", "analytics.dashboard",
@ -446,8 +471,9 @@ var DefaultRolePermissions = map[string][]string{
"vimeo.uploads.pull", "vimeo.uploads.tus", "vimeo.uploads.pull", "vimeo.uploads.tus",
// Team (full access) // Team (full access)
"team.profile.get_mine", "team.stats", "team.members.list", "team.members.create", "team.profile.get_mine", "team.stats", "team.members.list", "team.members.create", "team.members.invite",
"team.members.get", "team.members.update", "team.members.update_status", "team.members.delete", "team.members.change_password", "team.members.get", "team.members.update", "team.members.update_status", "team.members.delete", "team.members.change_password",
"team.invitations.list", "team.invitations.resend", "team.invitations.revoke",
// Sub-course Prerequisites // Sub-course Prerequisites
"subcourse_prerequisites.add", "subcourse_prerequisites.list", "subcourse_prerequisites.remove", "subcourse_prerequisites.add", "subcourse_prerequisites.list", "subcourse_prerequisites.remove",
@ -493,6 +519,8 @@ var DefaultRolePermissions = map[string][]string{
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get", "exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
"lms.get_my_progress", "lms.get_my_progress",
"personas.create", "personas.list", "personas.get", "personas.update", "personas.delete",
// Questions (full — instructors create content) // Questions (full — instructors create content)
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete", "questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete",
"question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete", "question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete",
@ -551,6 +579,8 @@ var DefaultRolePermissions = map[string][]string{
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get", "exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get", "exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
"personas.list", "personas.get",
// Questions (read) // Questions (read)
"questions.list", "questions.search", "questions.get", "questions.list", "questions.search", "questions.get",
"question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.list", "question_sets.list_by_owner", "question_sets.get",

View File

@ -0,0 +1,327 @@
package team
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/pkgs/helpers"
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net/url"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func (s *Service) InviteTeamMember(ctx context.Context, req domain.InviteTeamMemberReq, invitedBy *int64) (domain.InviteTeamMemberRes, error) {
if s.inviteBaseURL == "" {
return domain.InviteTeamMemberRes{}, domain.ErrTeamInviteBaseURLNotConfigured
}
if !domain.TeamRole(req.TeamRole).IsValid() {
return domain.InviteTeamMemberRes{}, domain.ErrInvalidTeamRole
}
if req.EmploymentType != "" && !domain.EmploymentType(req.EmploymentType).IsValid() {
return domain.InviteTeamMemberRes{}, domain.ErrInvalidEmploymentType
}
email := strings.TrimSpace(strings.ToLower(req.Email))
exists, err := s.teamStore.CheckTeamMemberEmailExists(ctx, email)
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
if exists {
return domain.InviteTeamMemberRes{}, domain.ErrTeamMemberEmailExists
}
placeholderPassword, err := randomPlaceholderPassword()
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(placeholderPassword), bcryptCost)
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
var hireDate *time.Time
if req.HireDate != "" {
parsed, err := time.Parse("2006-01-02", req.HireDate)
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
hireDate = &parsed
}
member := domain.TeamMember{
FirstName: strings.TrimSpace(req.FirstName),
LastName: strings.TrimSpace(req.LastName),
Email: email,
PhoneNumber: strings.TrimSpace(req.PhoneNumber),
Password: hashedPassword,
TeamRole: domain.TeamRole(req.TeamRole),
Department: strings.TrimSpace(req.Department),
JobTitle: strings.TrimSpace(req.JobTitle),
EmploymentType: domain.EmploymentType(req.EmploymentType),
HireDate: hireDate,
Status: domain.TeamMemberStatusInactive,
EmailVerified: false,
Permissions: req.Permissions,
CreatedBy: invitedBy,
}
created, err := s.teamStore.CreateTeamMember(ctx, member)
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
token, err := helpers.GenerateInviteToken()
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
expiresAt := time.Now().Add(s.inviteExpiry)
invitation, err := s.teamStore.CreateTeamInvitation(ctx, domain.TeamInvitation{
TeamMemberID: created.ID,
Token: token,
Status: domain.TeamInvitationStatusPending,
ExpiresAt: expiresAt,
InvitedBy: invitedBy,
})
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
inviterName := s.resolveInviterName(ctx, invitedBy)
inviteLink := buildInviteLink(s.inviteBaseURL, token)
if err := s.sendInvitationEmail(ctx, created, inviterName, inviteLink); err != nil {
return domain.InviteTeamMemberRes{}, err
}
return domain.InviteTeamMemberRes{
InvitationID: invitation.ID,
TeamMemberID: created.ID,
Email: created.Email,
ExpiresAt: expiresAt.Format(time.RFC3339),
}, nil
}
func (s *Service) ResendTeamInvitation(ctx context.Context, memberID int64, invitedBy *int64) (domain.InviteTeamMemberRes, error) {
if s.inviteBaseURL == "" {
return domain.InviteTeamMemberRes{}, domain.ErrTeamInviteBaseURLNotConfigured
}
member, err := s.teamStore.GetTeamMemberByID(ctx, memberID)
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
if member.Status != domain.TeamMemberStatusInactive || member.EmailVerified {
return domain.InviteTeamMemberRes{}, fmt.Errorf("team member is not awaiting invitation acceptance")
}
if err := s.teamStore.RevokePendingTeamInvitationsForMember(ctx, memberID); err != nil {
return domain.InviteTeamMemberRes{}, err
}
token, err := helpers.GenerateInviteToken()
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
expiresAt := time.Now().Add(s.inviteExpiry)
invitation, err := s.teamStore.CreateTeamInvitation(ctx, domain.TeamInvitation{
TeamMemberID: memberID,
Token: token,
Status: domain.TeamInvitationStatusPending,
ExpiresAt: expiresAt,
InvitedBy: invitedBy,
})
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
inviterName := s.resolveInviterName(ctx, invitedBy)
inviteLink := buildInviteLink(s.inviteBaseURL, token)
if err := s.sendInvitationEmail(ctx, member, inviterName, inviteLink); err != nil {
return domain.InviteTeamMemberRes{}, err
}
return domain.InviteTeamMemberRes{
InvitationID: invitation.ID,
TeamMemberID: member.ID,
Email: member.Email,
ExpiresAt: expiresAt.Format(time.RFC3339),
}, nil
}
func (s *Service) VerifyTeamInvitation(ctx context.Context, token string) (domain.VerifyTeamInvitationRes, error) {
inv, member, err := s.loadInvitationForToken(ctx, token)
if err != nil {
return domain.VerifyTeamInvitationRes{Valid: false, Status: string(domain.TeamInvitationStatusExpired)}, nil
}
return domain.VerifyTeamInvitationRes{
Valid: true,
Email: member.Email,
FirstName: member.FirstName,
LastName: member.LastName,
TeamRole: string(member.TeamRole),
ExpiresAt: inv.ExpiresAt,
Status: string(inv.Status),
}, nil
}
func (s *Service) AcceptTeamInvitation(ctx context.Context, req domain.AcceptTeamInvitationReq) (domain.TeamMember, error) {
inv, member, err := s.loadInvitationForToken(ctx, req.Token)
if err != nil {
return domain.TeamMember{}, err
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
if err != nil {
return domain.TeamMember{}, err
}
if err := s.teamStore.UpdateTeamMemberPassword(ctx, member.ID, string(hashedPassword)); err != nil {
return domain.TeamMember{}, err
}
if err := s.teamStore.UpdateTeamMemberStatus(ctx, domain.UpdateTeamMemberStatusReq{
TeamMemberID: member.ID,
Status: string(domain.TeamMemberStatusActive),
UpdatedBy: member.ID,
}); err != nil {
return domain.TeamMember{}, err
}
if err := s.teamStore.UpdateTeamMemberEmailVerified(ctx, member.ID, true); err != nil {
return domain.TeamMember{}, err
}
if _, err := s.teamStore.AcceptTeamInvitation(ctx, inv.ID); err != nil {
return domain.TeamMember{}, err
}
return s.teamStore.GetTeamMemberByID(ctx, member.ID)
}
func (s *Service) RevokeTeamInvitation(ctx context.Context, invitationID int64) error {
inv, err := s.teamStore.GetTeamInvitationByID(ctx, invitationID)
if err != nil {
return err
}
if inv.Status != domain.TeamInvitationStatusPending {
return fmt.Errorf("only pending invitations can be revoked")
}
if _, err := s.teamStore.RevokeTeamInvitation(ctx, invitationID); err != nil {
return err
}
member, err := s.teamStore.GetTeamMemberByID(ctx, inv.TeamMemberID)
if err != nil {
return err
}
if member.Status == domain.TeamMemberStatusInactive && !member.EmailVerified {
return s.teamStore.DeleteTeamMember(ctx, inv.TeamMemberID)
}
return nil
}
func (s *Service) ListTeamInvitations(ctx context.Context, status *string, limit, offset int32) ([]domain.TeamInvitationWithMember, int64, error) {
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.teamStore.ListTeamInvitations(ctx, status, limit, offset)
}
func (s *Service) loadInvitationForToken(ctx context.Context, token string) (domain.TeamInvitation, domain.TeamMember, error) {
inv, err := s.teamStore.GetTeamInvitationByToken(ctx, strings.TrimSpace(token))
if err != nil {
return domain.TeamInvitation{}, domain.TeamMember{}, err
}
now := time.Now()
if inv.Status == domain.TeamInvitationStatusPending && now.After(inv.ExpiresAt) {
_ = s.teamStore.ExpireTeamInvitation(ctx, inv.ID)
return domain.TeamInvitation{}, domain.TeamMember{}, domain.ErrTeamInvitationExpired
}
switch inv.Status {
case domain.TeamInvitationStatusAccepted:
return domain.TeamInvitation{}, domain.TeamMember{}, domain.ErrTeamInvitationAlreadyUsed
case domain.TeamInvitationStatusRevoked:
return domain.TeamInvitation{}, domain.TeamMember{}, domain.ErrTeamInvitationRevoked
case domain.TeamInvitationStatusExpired:
return domain.TeamInvitation{}, domain.TeamMember{}, domain.ErrTeamInvitationExpired
case domain.TeamInvitationStatusPending:
// continue
default:
return domain.TeamInvitation{}, domain.TeamMember{}, domain.ErrTeamInvitationNotFound
}
member, err := s.teamStore.GetTeamMemberByID(ctx, inv.TeamMemberID)
if err != nil {
return domain.TeamInvitation{}, domain.TeamMember{}, err
}
return inv, member, nil
}
func (s *Service) sendInvitationEmail(ctx context.Context, member domain.TeamMember, inviterName, inviteLink string) error {
if s.emailTemplateSvc == nil || s.messengerSvc == nil {
return fmt.Errorf("email services are not configured")
}
rendered, err := s.emailTemplateSvc.Render(ctx, domain.EmailTemplateSlugInvitation, map[string]any{
"FirstName": member.FirstName,
"InviterName": inviterName,
"InviteLink": inviteLink,
})
if err != nil {
return err
}
return s.messengerSvc.SendEmail(ctx, member.Email, rendered.Text, rendered.HTML, rendered.Subject)
}
func (s *Service) resolveInviterName(ctx context.Context, invitedBy *int64) string {
if invitedBy == nil {
return "Yimaru Academy"
}
inviter, err := s.teamStore.GetTeamMemberByID(ctx, *invitedBy)
if err != nil {
return "Yimaru Academy"
}
name := strings.TrimSpace(inviter.FirstName + " " + inviter.LastName)
if name == "" {
return "Yimaru Academy"
}
return name
}
func buildInviteLink(baseURL, token string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
u, err := url.Parse(base)
if err != nil {
return base + "?token=" + url.QueryEscape(token)
}
q := u.Query()
q.Set("token", token)
u.RawQuery = q.Encode()
return u.String()
}
func randomPlaceholderPassword() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}

View File

@ -2,19 +2,40 @@ package team
import ( import (
"Yimaru-Backend/internal/ports" "Yimaru-Backend/internal/ports"
emailtemplates "Yimaru-Backend/internal/services/emailtemplates"
"Yimaru-Backend/internal/services/messenger"
"time"
) )
type Service struct { type Service struct {
teamStore ports.TeamStore teamStore ports.TeamStore
refreshExpirySec int refreshExpirySec int
emailTemplateSvc *emailtemplates.Service
messengerSvc *messenger.Service
inviteBaseURL string
inviteExpiry time.Duration
} }
func NewService(teamStore ports.TeamStore, refreshExpirySeconds int) *Service { func NewService(
teamStore ports.TeamStore,
refreshExpirySeconds int,
emailTemplateSvc *emailtemplates.Service,
messengerSvc *messenger.Service,
inviteBaseURL string,
inviteExpiry time.Duration,
) *Service {
if refreshExpirySeconds <= 0 { if refreshExpirySeconds <= 0 {
refreshExpirySeconds = 7 * 24 * 3600 refreshExpirySeconds = 7 * 24 * 3600
} }
if inviteExpiry <= 0 {
inviteExpiry = 7 * 24 * time.Hour
}
return &Service{ return &Service{
teamStore: teamStore, teamStore: teamStore,
refreshExpirySec: refreshExpirySeconds, refreshExpirySec: refreshExpirySeconds,
emailTemplateSvc: emailTemplateSvc,
messengerSvc: messengerSvc,
inviteBaseURL: inviteBaseURL,
inviteExpiry: inviteExpiry,
} }
} }

View File

@ -133,6 +133,13 @@ func (s *Service) Login(ctx context.Context, req domain.TeamMemberLoginReq) (dom
return domain.TeamMember{}, err return domain.TeamMember{}, err
} }
if member.Status != domain.TeamMemberStatusActive {
return domain.TeamMember{}, domain.ErrInvalidTeamMemberStatus
}
if !member.EmailVerified {
return domain.TeamMember{}, domain.ErrTeamMemberPendingInvitation
}
if err := bcrypt.CompareHashAndPassword(member.Password, []byte(req.Password)); err != nil { if err := bcrypt.CompareHashAndPassword(member.Password, []byte(req.Password)); err != nil {
return domain.TeamMember{}, err return domain.TeamMember{}, err
} }

View File

@ -10,6 +10,14 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func (s *Service) renderOtpMessage(ctx context.Context, otpCode, firstName string) (domain.RenderedEmail, error) {
return s.emailTemplateSvc.Render(ctx, domain.EmailTemplateSlugOTP, map[string]any{
"OTP": otpCode,
"FirstName": firstName,
"ExpiresMinutes": int(OtpExpiry.Minutes()),
})
}
func (s *Service) ResendOtp( func (s *Service) ResendOtp(
ctx context.Context, ctx context.Context,
email, phone string, email, phone string,
@ -22,29 +30,28 @@ func (s *Service) ResendOtp(
otpCode := helpers.GenerateOTP() otpCode := helpers.GenerateOTP()
message := fmt.Sprintf(
"Welcome to Yimaru Online Learning Platform, your OTP is %s please don't share with anyone.",
otpCode,
)
otp, err := s.otpStore.GetOtp(ctx, user.ID) otp, err := s.otpStore.GetOtp(ctx, user.ID)
if err != nil { if err != nil {
return err return err
} }
// Broadcast OTP (same logic as SendOtp) rendered, err := s.renderOtpMessage(ctx, otpCode, user.FirstName)
if err != nil {
return err
}
switch otp.Medium { switch otp.Medium {
case domain.OtpMediumSms: case domain.OtpMediumSms:
if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, otp.SentTo, message, nil); err != nil { if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, otp.SentTo, rendered.Text, nil); err != nil {
return err return err
} }
case domain.OtpMediumEmail: case domain.OtpMediumEmail:
if err := s.messengerSvc.SendEmail( if err := s.messengerSvc.SendEmail(
ctx, ctx,
otp.SentTo, otp.SentTo,
message, rendered.Text,
message, rendered.HTML,
"Yimaru - One Time Password", rendered.Subject,
); err != nil { ); err != nil {
return err return err
} }
@ -63,16 +70,26 @@ func (s *Service) ResendOtp(
func (s *Service) SendOtp(ctx context.Context, userID int64, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium, provider domain.SMSProvider) error { func (s *Service) SendOtp(ctx context.Context, userID int64, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium, provider domain.SMSProvider) error {
otpCode := helpers.GenerateOTP() otpCode := helpers.GenerateOTP()
message := fmt.Sprintf("Welcome to Yimaru Online Learning Platform, your OTP is %s please don't share with anyone.", otpCode) firstName := ""
if userID > 0 {
user, err := s.userStore.GetUserByID(ctx, userID)
if err == nil {
firstName = user.FirstName
}
}
rendered, err := s.renderOtpMessage(ctx, otpCode, firstName)
if err != nil {
return err
}
switch medium { switch medium {
case domain.OtpMediumSms: case domain.OtpMediumSms:
if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, sentTo, rendered.Text, nil); err != nil {
if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, sentTo, message, nil); err != nil {
return err return err
} }
case domain.OtpMediumEmail: case domain.OtpMediumEmail:
if err := s.messengerSvc.SendEmail(ctx, sentTo, message, message, "Yimaru - One Time Password"); err != nil { if err := s.messengerSvc.SendEmail(ctx, sentTo, rendered.Text, rendered.HTML, rendered.Subject); err != nil {
return err return err
} }
} }

View File

@ -3,6 +3,7 @@ package user
import ( import (
"Yimaru-Backend/internal/config" "Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/ports" "Yimaru-Backend/internal/ports"
emailtemplates "Yimaru-Backend/internal/services/emailtemplates"
"Yimaru-Backend/internal/services/messenger" "Yimaru-Backend/internal/services/messenger"
"time" "time"
) )
@ -12,11 +13,12 @@ const (
) )
type Service struct { type Service struct {
tokenStore ports.TokenStore tokenStore ports.TokenStore
userStore ports.UserStore userStore ports.UserStore
otpStore ports.OtpStore otpStore ports.OtpStore
messengerSvc *messenger.Service messengerSvc *messenger.Service
config *config.Config emailTemplateSvc *emailtemplates.Service
config *config.Config
} }
func NewService( func NewService(
@ -24,13 +26,15 @@ func NewService(
userStore ports.UserStore, userStore ports.UserStore,
otpStore ports.OtpStore, otpStore ports.OtpStore,
messengerSvc *messenger.Service, messengerSvc *messenger.Service,
emailTemplateSvc *emailtemplates.Service,
cfg *config.Config, cfg *config.Config,
) *Service { ) *Service {
return &Service{ return &Service{
tokenStore: tokenStore, tokenStore: tokenStore,
userStore: userStore, userStore: userStore,
otpStore: otpStore, otpStore: otpStore,
messengerSvc: messengerSvc, messengerSvc: messengerSvc,
config: cfg, emailTemplateSvc: emailTemplateSvc,
config: cfg,
} }
} }

View File

@ -5,10 +5,12 @@ import (
"Yimaru-Backend/internal/config" "Yimaru-Backend/internal/config"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/chapa"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
"Yimaru-Backend/internal/services/courses" "Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/emailtemplates"
"Yimaru-Backend/internal/services/examprep" "Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/faqs" "Yimaru-Backend/internal/services/faqs"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
@ -16,7 +18,8 @@ import (
"Yimaru-Backend/internal/services/lmsprogress" "Yimaru-Backend/internal/services/lmsprogress"
minioservice "Yimaru-Backend/internal/services/minio" minioservice "Yimaru-Backend/internal/services/minio"
"Yimaru-Backend/internal/services/modules" "Yimaru-Backend/internal/services/modules"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/personas"
"Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/practices"
"Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
@ -48,6 +51,8 @@ type App struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
questionsSvc *questions.Service questionsSvc *questions.Service
faqSvc *faqs.Service faqSvc *faqs.Service
emailTemplateSvc *emailtemplates.Service
personaSvc *personas.Service
examPrepSvc *examprep.Service examPrepSvc *examprep.Service
programSvc *programs.Service programSvc *programs.Service
courseSvc *courses.Service courseSvc *courses.Service
@ -57,6 +62,7 @@ type App struct {
practiceSvc *practices.Service practiceSvc *practices.Service
subscriptionsSvc *subscriptions.Service subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
chapaSvc *chapa.Service
issueReportingSvc *issuereporting.Service issueReportingSvc *issuereporting.Service
vimeoSvc *vimeoservice.Service vimeoSvc *vimeoservice.Service
teamSvc *team.Service teamSvc *team.Service
@ -87,6 +93,8 @@ func NewApp(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
faqSvc *faqs.Service, faqSvc *faqs.Service,
emailTemplateSvc *emailtemplates.Service,
personaSvc *personas.Service,
examPrepSvc *examprep.Service, examPrepSvc *examprep.Service,
programSvc *programs.Service, programSvc *programs.Service,
courseSvc *courses.Service, courseSvc *courses.Service,
@ -96,6 +104,7 @@ func NewApp(
practiceSvc *practices.Service, practiceSvc *practices.Service,
subscriptionsSvc *subscriptions.Service, subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
chapaSvc *chapa.Service,
issueReportingSvc *issuereporting.Service, issueReportingSvc *issuereporting.Service,
vimeoSvc *vimeoservice.Service, vimeoSvc *vimeoservice.Service,
teamSvc *team.Service, teamSvc *team.Service,
@ -138,6 +147,8 @@ func NewApp(
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
faqSvc: faqSvc, faqSvc: faqSvc,
emailTemplateSvc: emailTemplateSvc,
personaSvc: personaSvc,
examPrepSvc: examPrepSvc, examPrepSvc: examPrepSvc,
programSvc: programSvc, programSvc: programSvc,
courseSvc: courseSvc, courseSvc: courseSvc,
@ -147,6 +158,7 @@ func NewApp(
practiceSvc: practiceSvc, practiceSvc: practiceSvc,
subscriptionsSvc: subscriptionsSvc, subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,
chapaSvc: chapaSvc,
vimeoSvc: vimeoSvc, vimeoSvc: vimeoSvc,
teamSvc: teamSvc, teamSvc: teamSvc,
activityLogSvc: activityLogSvc, activityLogSvc: activityLogSvc,

View File

@ -1,7 +1,10 @@
package handlers package handlers
import ( import (
"errors"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/chapa"
"strconv" "strconv"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@ -65,14 +68,16 @@ func (h *Handler) InitiateSubscriptionPayment(c *fiber.Ctx) error {
}) })
} }
result, err := h.arifpaySvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
PlanID: req.PlanID, PlanID: req.PlanID,
Phone: req.Phone, Phone: req.Phone,
Email: req.Email, Email: req.Email,
}) })
if err != nil { if err != nil {
status := fiber.StatusInternalServerError status := fiber.StatusInternalServerError
if err.Error() == "user already has an active subscription" { if errors.Is(err, chapa.ErrChapaNotConfigured) {
status = fiber.StatusServiceUnavailable
} else if err.Error() == "user already has an active subscription" {
status = fiber.StatusConflict status = fiber.StatusConflict
} }
return c.Status(status).JSON(domain.ErrorResponse{ return c.Status(status).JSON(domain.ErrorResponse{
@ -105,7 +110,7 @@ func (h *Handler) VerifyPayment(c *fiber.Ctx) error {
}) })
} }
payment, err := h.arifpaySvc.VerifyPayment(c.Context(), sessionID) payment, err := h.chapaSvc.VerifyPayment(c.Context(), sessionID)
if err != nil { if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Payment not found or verification failed", Message: "Payment not found or verification failed",
@ -143,7 +148,7 @@ func (h *Handler) GetMyPayments(c *fiber.Ctx) error {
limit, _ := strconv.Atoi(c.Query("limit", "20")) limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0")) offset, _ := strconv.Atoi(c.Query("offset", "0"))
payments, err := h.arifpaySvc.GetPaymentsByUser(c.Context(), userID, int32(limit), int32(offset)) payments, err := h.chapaSvc.GetPaymentsByUser(c.Context(), userID, int32(limit), int32(offset))
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get payment history", Message: "Failed to get payment history",
@ -186,7 +191,7 @@ func (h *Handler) GetPaymentByID(c *fiber.Ctx) error {
}) })
} }
payment, err := h.arifpaySvc.GetPaymentByID(c.Context(), id) payment, err := h.chapaSvc.GetPaymentByID(c.Context(), id)
if err != nil { if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Payment not found", Message: "Payment not found",
@ -229,7 +234,7 @@ func (h *Handler) CancelPayment(c *fiber.Ctx) error {
}) })
} }
if err := h.arifpaySvc.CancelPayment(c.Context(), id, userID); err != nil { if err := h.chapaSvc.CancelPayment(c.Context(), id, userID); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to cancel payment", Message: "Failed to cancel payment",
Error: err.Error(), Error: err.Error(),

View File

@ -0,0 +1,114 @@
package handlers
import (
"encoding/json"
"errors"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/chapa"
"github.com/gofiber/fiber/v2"
)
// HandleChapaWebhook godoc
// @Summary Handle Chapa webhook
// @Description Processes payment notifications from Chapa (charge.success, etc.)
// @Tags payments
// @Accept json
// @Produce json
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Router /api/v1/payments/webhook [post]
func (h *Handler) HandleChapaWebhook(c *fiber.Ctx) error {
body := c.Body()
signature := c.Get("x-chapa-signature")
if signature == "" {
signature = c.Get("chapa-signature")
}
if err := h.chapaSvc.VerifyWebhookSignature(body, signature); err != nil {
h.logger.Error("Invalid Chapa webhook signature", "error", err)
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Invalid webhook signature",
})
}
var payload domain.ChapaWebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid webhook payload",
Error: err.Error(),
})
}
if err := h.chapaSvc.ProcessPaymentWebhook(c.Context(), payload); err != nil {
if errors.Is(err, chapa.ErrPaymentAlreadyPaid) {
return c.JSON(domain.Response{Message: "Webhook already processed"})
}
h.logger.Error("Failed to process Chapa webhook", "error", err, "tx_ref", payload.TxRef)
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to process webhook",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Webhook processed successfully",
})
}
// HandleChapaCallback godoc
// @Summary Chapa payment callback
// @Description Verifies payment after Chapa redirects to callback_url
// @Tags payments
// @Produce json
// @Param trx_ref query string false "Transaction reference"
// @Param ref_id query string false "Chapa reference ID"
// @Param status query string false "Payment status"
// @Success 200 {object} domain.Response
// @Router /api/v1/payments/chapa/callback [get]
func (h *Handler) HandleChapaCallback(c *fiber.Ctx) error {
query := domain.ChapaCallbackQuery{
TrxRef: c.Query("trx_ref"),
RefID: c.Query("ref_id"),
Status: c.Query("status"),
}
if query.TrxRef == "" {
query.TrxRef = c.Query("tx_ref")
}
if query.TrxRef == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "trx_ref is required",
})
}
if err := h.chapaSvc.ProcessCallback(c.Context(), query); err != nil {
if errors.Is(err, chapa.ErrPaymentAlreadyPaid) {
return c.JSON(domain.Response{Message: "Payment already processed"})
}
h.logger.Error("Failed to process Chapa callback", "error", err, "trx_ref", query.TrxRef)
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to process callback",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Callback processed successfully",
})
}
// GetChapaPaymentMethods godoc
// @Summary Get Chapa payment methods
// @Description Returns payment methods available on Chapa checkout
// @Tags payments
// @Produce json
// @Success 200 {object} domain.Response
// @Router /api/v1/payments/methods [get]
func (h *Handler) GetChapaPaymentMethods(c *fiber.Ctx) error {
return c.JSON(domain.Response{
Message: "Payment methods retrieved successfully",
Data: h.chapaSvc.GetPaymentMethods(),
})
}

View File

@ -0,0 +1,467 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"errors"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
type createEmailTemplateReq struct {
Slug string `json:"slug" validate:"required"`
Name string `json:"name" validate:"required"`
Subject string `json:"subject" validate:"required"`
BodyText string `json:"body_text" validate:"required"`
BodyHTML string `json:"body_html" validate:"required"`
Variables []string `json:"variables"`
Status *string `json:"status"`
}
type updateEmailTemplateReq struct {
Name *string `json:"name"`
Subject *string `json:"subject"`
BodyText *string `json:"body_text"`
BodyHTML *string `json:"body_html"`
Variables []string `json:"variables"`
Status *string `json:"status"`
}
type previewEmailTemplateReq struct {
Variables map[string]any `json:"variables"`
}
type emailTemplateRes struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Subject string `json:"subject"`
BodyText string `json:"body_text"`
BodyHTML string `json:"body_html"`
Variables []string `json:"variables"`
IsSystem bool `json:"is_system"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt *string `json:"updated_at,omitempty"`
}
type listEmailTemplatesRes struct {
Templates []emailTemplateRes `json:"templates"`
TotalCount int64 `json:"total_count"`
}
func mapEmailTemplateToRes(t domain.EmailTemplate) emailTemplateRes {
var updatedAt *string
if t.UpdatedAt != nil {
value := t.UpdatedAt.String()
updatedAt = &value
}
variables := t.Variables
if variables == nil {
variables = []string{}
}
return emailTemplateRes{
ID: t.ID,
Slug: t.Slug,
Name: t.Name,
Subject: t.Subject,
BodyText: t.BodyText,
BodyHTML: t.BodyHTML,
Variables: variables,
IsSystem: t.IsSystem,
Status: t.Status,
CreatedAt: t.CreatedAt.String(),
UpdatedAt: updatedAt,
}
}
// ListEmailTemplatesAdmin godoc
// @Summary List email templates (admin)
// @Description Returns email templates for admin management
// @Tags email-templates
// @Produce json
// @Param status query string false "ACTIVE or INACTIVE"
// @Param query query string false "Search by slug, name, or subject"
// @Param limit query int false "Limit (default 20)"
// @Param offset query int false "Offset (default 0)"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates [get]
func (h *Handler) ListEmailTemplatesAdmin(c *fiber.Ctx) error {
status := strings.ToUpper(strings.TrimSpace(c.Query("status")))
var statusPtr *string
if status != "" {
statusPtr = &status
}
search := strings.TrimSpace(c.Query("query"))
var searchPtr *string
if search != "" {
searchPtr = &search
}
limit, err := strconv.Atoi(c.Query("limit", "20"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid limit",
Error: err.Error(),
})
}
offset, err := strconv.Atoi(c.Query("offset", "0"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid offset",
Error: err.Error(),
})
}
templates, total, err := h.emailTemplateSvc.ListEmailTemplates(c.Context(), statusPtr, searchPtr, int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to list email templates",
Error: err.Error(),
})
}
out := make([]emailTemplateRes, 0, len(templates))
for _, tmpl := range templates {
out = append(out, mapEmailTemplateToRes(tmpl))
}
return c.JSON(domain.Response{
Message: "Email templates retrieved successfully",
Data: listEmailTemplatesRes{
Templates: out,
TotalCount: total,
},
})
}
// GetEmailTemplateByIDAdmin godoc
// @Summary Get email template by ID (admin)
// @Description Returns one email template regardless of status
// @Tags email-templates
// @Produce json
// @Param id path int true "Email template ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/{id} [get]
func (h *Handler) GetEmailTemplateByIDAdmin(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template ID",
Error: "id must be a positive integer",
})
}
tmpl, err := h.emailTemplateSvc.GetEmailTemplateByID(c.Context(), id, true)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Email template not found",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template retrieved successfully",
Data: mapEmailTemplateToRes(tmpl),
})
}
// GetEmailTemplateBySlugAdmin godoc
// @Summary Get email template by slug (admin)
// @Description Returns one email template by slug regardless of status
// @Tags email-templates
// @Produce json
// @Param slug path string true "Email template slug"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/slug/{slug} [get]
func (h *Handler) GetEmailTemplateBySlugAdmin(c *fiber.Ctx) error {
slug := strings.TrimSpace(c.Params("slug"))
if slug == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template slug",
Error: "slug is required",
})
}
tmpl, err := h.emailTemplateSvc.GetEmailTemplateBySlug(c.Context(), slug, true)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Email template not found",
})
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to get email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template retrieved successfully",
Data: mapEmailTemplateToRes(tmpl),
})
}
// CreateEmailTemplate godoc
// @Summary Create email template
// @Description Creates a new custom email template
// @Tags email-templates
// @Accept json
// @Produce json
// @Param body body createEmailTemplateReq true "Create email template payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates [post]
func (h *Handler) CreateEmailTemplate(c *fiber.Ctx) error {
var req createEmailTemplateReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
tmpl, err := h.emailTemplateSvc.CreateEmailTemplate(c.Context(), domain.CreateEmailTemplateInput{
Slug: req.Slug,
Name: req.Name,
Subject: req.Subject,
BodyText: req.BodyText,
BodyHTML: req.BodyHTML,
Variables: req.Variables,
Status: req.Status,
})
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to create email template",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Email template created successfully",
Data: mapEmailTemplateToRes(tmpl),
})
}
// UpdateEmailTemplate godoc
// @Summary Update email template
// @Description Updates an existing email template
// @Tags email-templates
// @Accept json
// @Produce json
// @Param id path int true "Email template ID"
// @Param body body updateEmailTemplateReq true "Update email template payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/{id} [put]
func (h *Handler) UpdateEmailTemplate(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template ID",
Error: "id must be a positive integer",
})
}
var req updateEmailTemplateReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
tmpl, err := h.emailTemplateSvc.UpdateEmailTemplate(c.Context(), id, domain.UpdateEmailTemplateInput{
Name: req.Name,
Subject: req.Subject,
BodyText: req.BodyText,
BodyHTML: req.BodyHTML,
Variables: req.Variables,
Status: req.Status,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Email template not found",
})
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to update email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template updated successfully",
Data: mapEmailTemplateToRes(tmpl),
})
}
// DeleteEmailTemplate godoc
// @Summary Delete email template
// @Description Deletes a custom email template
// @Tags email-templates
// @Produce json
// @Param id path int true "Email template ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/{id} [delete]
func (h *Handler) DeleteEmailTemplate(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template ID",
Error: "id must be a positive integer",
})
}
if err := h.emailTemplateSvc.DeleteEmailTemplate(c.Context(), id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Email template not found",
})
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to delete email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template deleted successfully",
Data: fiber.Map{"id": id},
})
}
// PreviewEmailTemplateBySlug godoc
// @Summary Preview email template by slug
// @Description Renders an email template with sample variables without sending
// @Tags email-templates
// @Accept json
// @Produce json
// @Param slug path string true "Email template slug"
// @Param body body previewEmailTemplateReq true "Preview variables"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/slug/{slug}/preview [post]
func (h *Handler) PreviewEmailTemplateBySlug(c *fiber.Ctx) error {
slug := strings.TrimSpace(c.Params("slug"))
if slug == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template slug",
Error: "slug is required",
})
}
var req previewEmailTemplateReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
rendered, err := h.emailTemplateSvc.PreviewEmailTemplate(c.Context(), domain.PreviewEmailTemplateInput{
Slug: slug,
Variables: req.Variables,
})
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to preview email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template preview generated successfully",
Data: rendered,
})
}
// PreviewEmailTemplateByID godoc
// @Summary Preview email template by ID
// @Description Renders an email template with sample variables without sending
// @Tags email-templates
// @Accept json
// @Produce json
// @Param id path int true "Email template ID"
// @Param body body previewEmailTemplateReq true "Preview variables"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/{id}/preview [post]
func (h *Handler) PreviewEmailTemplateByID(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template ID",
Error: "id must be a positive integer",
})
}
var req previewEmailTemplateReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
tmpl, err := h.emailTemplateSvc.GetEmailTemplateByID(c.Context(), id, true)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Email template not found",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get email template",
Error: err.Error(),
})
}
rendered, err := h.emailTemplateSvc.PreviewEmailTemplate(c.Context(), domain.PreviewEmailTemplateInput{
Slug: tmpl.Slug,
Variables: req.Variables,
})
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to preview email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template preview generated successfully",
Data: rendered,
})
}

View File

@ -44,6 +44,12 @@ func (h *Handler) CreateExamPrepPractice(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if errors.Is(err, domain.ErrPersonaNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Persona not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create practice", Message: "Failed to create practice",
Error: err.Error(), Error: err.Error(),
@ -167,6 +173,12 @@ func (h *Handler) UpdateExamPrepPractice(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if errors.Is(err, domain.ErrPersonaNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Persona not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update practice", Message: "Failed to update practice",
Error: err.Error(), Error: err.Error(),

View File

@ -11,7 +11,7 @@ import (
// CreateExamPrepUnit godoc // CreateExamPrepUnit godoc
// @Summary Create exam-prep unit // @Summary Create exam-prep unit
// @Description Unit under a catalog course (e.g. chapter title) // @Description Unit under a catalog course (e.g. chapter title). Optional sort_order assigns position within that catalog course (siblings at or after that index are shifted); omit to append after the current highest sort_order in the catalog course.
// @Tags exam-prep // @Tags exam-prep
// @Accept json // @Accept json
// @Produce json // @Produce json

View File

@ -8,10 +8,12 @@ import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/chapa"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
"Yimaru-Backend/internal/services/courses" "Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/emailtemplates"
"Yimaru-Backend/internal/services/examprep" "Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/faqs" "Yimaru-Backend/internal/services/faqs"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
@ -19,7 +21,8 @@ import (
"Yimaru-Backend/internal/services/lmsprogress" "Yimaru-Backend/internal/services/lmsprogress"
minioservice "Yimaru-Backend/internal/services/minio" minioservice "Yimaru-Backend/internal/services/minio"
"Yimaru-Backend/internal/services/modules" "Yimaru-Backend/internal/services/modules"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/personas"
"Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/practices"
"Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
@ -47,6 +50,8 @@ type Handler struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
questionsSvc *questions.Service questionsSvc *questions.Service
faqSvc *faqs.Service faqSvc *faqs.Service
emailTemplateSvc *emailtemplates.Service
personaSvc *personas.Service
examPrepSvc *examprep.Service examPrepSvc *examprep.Service
programSvc *programs.Service programSvc *programs.Service
courseSvc *courses.Service courseSvc *courses.Service
@ -56,6 +61,7 @@ type Handler struct {
practiceSvc *practices.Service practiceSvc *practices.Service
subscriptionsSvc *subscriptions.Service subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
chapaSvc *chapa.Service
logger *slog.Logger logger *slog.Logger
settingSvc *settings.Service settingSvc *settings.Service
notificationSvc *notificationservice.Service notificationSvc *notificationservice.Service
@ -82,6 +88,8 @@ func New(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
faqSvc *faqs.Service, faqSvc *faqs.Service,
emailTemplateSvc *emailtemplates.Service,
personaSvc *personas.Service,
examPrepSvc *examprep.Service, examPrepSvc *examprep.Service,
programSvc *programs.Service, programSvc *programs.Service,
courseSvc *courses.Service, courseSvc *courses.Service,
@ -91,6 +99,7 @@ func New(
practiceSvc *practices.Service, practiceSvc *practices.Service,
subscriptionsSvc *subscriptions.Service, subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
chapaSvc *chapa.Service,
logger *slog.Logger, logger *slog.Logger,
settingSvc *settings.Service, settingSvc *settings.Service,
notificationSvc *notificationservice.Service, notificationSvc *notificationservice.Service,
@ -116,6 +125,8 @@ func New(
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
faqSvc: faqSvc, faqSvc: faqSvc,
emailTemplateSvc: emailTemplateSvc,
personaSvc: personaSvc,
examPrepSvc: examPrepSvc, examPrepSvc: examPrepSvc,
programSvc: programSvc, programSvc: programSvc,
courseSvc: courseSvc, courseSvc: courseSvc,
@ -125,6 +136,7 @@ func New(
practiceSvc: practiceSvc, practiceSvc: practiceSvc,
subscriptionsSvc: subscriptionsSvc, subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,
chapaSvc: chapaSvc,
logger: logger, logger: logger,
settingSvc: settingSvc, settingSvc: settingSvc,
notificationSvc: notificationSvc, notificationSvc: notificationSvc,

View File

@ -84,7 +84,8 @@ func (h *Handler) ListLessonsByModule(c *fiber.Ctx) error {
} }
limit, _ := strconv.Atoi(c.Query("limit", "20")) limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0")) offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.lessonSvc.ListByModule(c.Context(), moduleID, int32(limit), int32(offset)) publishedOnly := !h.canManageLessons(c)
items, total, err := h.lessonSvc.ListByModule(c.Context(), moduleID, publishedOnly, int32(limit), int32(offset))
if err != nil { if err != nil {
if errors.Is(err, modules.ErrModuleNotFound) { if errors.Is(err, modules.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
@ -145,6 +146,12 @@ func (h *Handler) GetLesson(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if !les.VisibleToLearners() && !h.canManageLessons(c) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found",
Error: lessons.ErrLessonNotFound.Error(),
})
}
uid := c.Locals("user_id").(int64) uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role) role := c.Locals("role").(domain.Role)
if err := h.lmsProgressSvc.ApplyAccessLesson(c.Context(), role, uid, &les); err != nil { if err := h.lmsProgressSvc.ApplyAccessLesson(c.Context(), role, uid, &les); err != nil {
@ -265,7 +272,8 @@ func (h *Handler) CompleteLesson(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if _, err := h.lessonSvc.GetByID(c.Context(), id); err != nil { les, err := h.lessonSvc.GetByID(c.Context(), id)
if err != nil {
if errors.Is(err, lessons.ErrLessonNotFound) { if errors.Is(err, lessons.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found", Message: "Lesson not found",
@ -277,8 +285,14 @@ func (h *Handler) CompleteLesson(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role) role := c.Locals("role").(domain.Role)
if role.IsCustomerLearnerRole() && !les.VisibleToLearners() {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Only published lessons can be completed",
Error: "LESSON_NOT_PUBLISHED",
})
}
uid := c.Locals("user_id").(int64)
if role.UsesLMSSequentialGating() { if role.UsesLMSSequentialGating() {
ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id) ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id)
if err != nil { if err != nil {

View File

@ -0,0 +1,166 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"errors"
personasservice "Yimaru-Backend/internal/services/personas"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
)
type listPersonasData struct {
Personas []domain.LmsPersona `json:"personas"`
TotalCount int64 `json:"total_count"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// CreatePersona godoc
// @Summary Create LMS persona catalog entry
// @Tags personas
// @Accept json
// @Param body body domain.CreateLmsPersonaInput true "Persona"
// @Success 201 {object} domain.Response
// @Router /api/v1/personas [post]
func (h *Handler) CreatePersona(c *fiber.Ctx) error {
var req domain.CreateLmsPersonaInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
p, err := h.personaSvc.Create(c.Context(), req)
if err != nil {
if errors.Is(err, personasservice.ErrNameRequired) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Validation failed", Error: err.Error()})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create persona", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Persona created successfully",
Data: p,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ListPersonas godoc
// @Summary List LMS personas (catalog for practice assignment)
// @Tags personas
// @Param active_only query bool false "When true (default), return only active personas" default(true)
// @Param limit query int false "Page size"
// @Param offset query int false "Offset"
// @Router /api/v1/personas [get]
func (h *Handler) ListPersonas(c *fiber.Ctx) error {
activeOnlyStr := strings.ToLower(strings.TrimSpace(c.Query("active_only", "true")))
activeOnly := activeOnlyStr != "false"
limit, err := strconv.Atoi(c.Query("limit", "20"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid limit", Error: err.Error()})
}
offset, err := strconv.Atoi(c.Query("offset", "0"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid offset", Error: err.Error()})
}
items, total, err := h.personaSvc.List(c.Context(), activeOnly, int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list personas", Error: err.Error()})
}
return c.JSON(domain.Response{
Message: "Personas retrieved successfully",
Data: listPersonasData{
Personas: items,
TotalCount: total,
Limit: limit,
Offset: offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetPersona godoc
// @Summary Get LMS persona by ID
// @Tags personas
// @Param id path int true "Persona ID"
// @Router /api/v1/personas/{id} [get]
func (h *Handler) GetPersona(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
msg := ""
if err != nil {
msg = err.Error()
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid persona id", Error: msg})
}
p, err := h.personaSvc.GetByID(c.Context(), id)
if err != nil {
if errors.Is(err, domain.ErrPersonaNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona not found", Error: err.Error()})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load persona", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Persona retrieved successfully", Data: p, Success: true, StatusCode: fiber.StatusOK})
}
// UpdatePersona godoc
// @Summary Update LMS persona
// @Tags personas
// @Param id path int true "Persona ID"
// @Param body body domain.UpdateLmsPersonaInput true "Fields to update"
// @Router /api/v1/personas/{id} [put]
func (h *Handler) UpdatePersona(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
msg := ""
if err != nil {
msg = err.Error()
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid persona id", Error: msg})
}
var req domain.UpdateLmsPersonaInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
p, err := h.personaSvc.Update(c.Context(), id, req)
if err != nil {
if errors.Is(err, domain.ErrPersonaNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona not found", Error: err.Error()})
}
if errors.Is(err, personasservice.ErrNameEmptyUpdate) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Validation failed", Error: err.Error()})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update persona", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Persona updated successfully", Data: p, Success: true, StatusCode: fiber.StatusOK})
}
// DeletePersona godoc
// @Summary Delete LMS persona (practices referencing it will have persona_id cleared)
// @Tags personas
// @Param id path int true "Persona ID"
// @Router /api/v1/personas/{id} [delete]
func (h *Handler) DeletePersona(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
msg := ""
if err != nil {
msg = err.Error()
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid persona id", Error: msg})
}
if err := h.personaSvc.Delete(c.Context(), id); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete persona", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Persona deleted successfully", Success: true, StatusCode: fiber.StatusOK})
}

View File

@ -727,9 +727,9 @@ func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error {
func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error { func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
title := c.FormValue("title", "Test Push Notification") title := c.FormValue("title", "Test Push Notification")
message := c.FormValue("message", "This is a test push notification from Yimaru Backend") message := c.FormValue("message", "This is a test push notification from Yimaru Backend")
h.mongoLoggerSvc.Info("FCM_SERVICE_ACCOUNT_KEY value during test-push call", h.mongoLoggerSvc.Info("notification test-push diagnostics",
zap.String("fcm_service_account_key", h.Cfg.FCMServiceAccountKey), zap.Bool("fcm_credentials_configured", strings.TrimSpace(h.Cfg.FCMServiceAccountKey) != ""),
zap.String("db_url", h.Cfg.DbUrl), zap.Int("fcm_service_account_json_len", len(strings.TrimSpace(h.Cfg.FCMServiceAccountKey))),
) )
userID, ok := c.Locals("user_id").(int64) userID, ok := c.Locals("user_id").(int64)

View File

@ -43,8 +43,8 @@ func (h *Handler) CreatePractice(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Lesson not found", Error: err.Error()}) return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Lesson not found", Error: err.Error()})
case errors.Is(err, practices.ErrQuestionSetNotFound): case errors.Is(err, practices.ErrQuestionSetNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Question set not found", Error: err.Error()}) return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Question set not found", Error: err.Error()})
case errors.Is(err, domain.ErrUserNotFound): case errors.Is(err, domain.ErrPersonaNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona user not found", Error: err.Error()}) return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona not found", Error: err.Error()})
case errors.Is(err, practices.ErrInvalidPracticeParent): case errors.Is(err, practices.ErrInvalidPracticeParent):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid parent", Error: err.Error()}) return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid parent", Error: err.Error()})
} }
@ -205,8 +205,8 @@ func (h *Handler) UpdatePractice(c *fiber.Ctx) error {
if errors.Is(err, practices.ErrQuestionSetNotFound) { if errors.Is(err, practices.ErrQuestionSetNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Question set not found", Error: err.Error()}) return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Question set not found", Error: err.Error()})
} }
if errors.Is(err, domain.ErrUserNotFound) { if errors.Is(err, domain.ErrPersonaNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona user not found", Error: err.Error()}) return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona not found", Error: err.Error()})
} }
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update practice", Error: err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update practice", Error: err.Error()})
} }

View File

@ -11,6 +11,11 @@ func (h *Handler) canManageLMSPractices(c *fiber.Ctx) bool {
return h.rbacSvc.HasPermission(rn, "practices.create") || h.rbacSvc.HasPermission(rn, "practices.update") return h.rbacSvc.HasPermission(rn, "practices.create") || h.rbacSvc.HasPermission(rn, "practices.update")
} }
func (h *Handler) canManageLessons(c *fiber.Ctx) bool {
rn := string(c.Locals("role").(domain.Role))
return h.rbacSvc.HasPermission(rn, "lessons.create") || h.rbacSvc.HasPermission(rn, "lessons.update")
}
func (h *Handler) canManageExamPrepPractices(c *fiber.Ctx) bool { func (h *Handler) canManageExamPrepPractices(c *fiber.Ctx) bool {
rn := string(c.Locals("role").(domain.Role)) rn := string(c.Locals("role").(domain.Role))
return h.rbacSvc.HasPermission(rn, "exam_prep.practices.create") || h.rbacSvc.HasPermission(rn, "exam_prep.practices.update") return h.rbacSvc.HasPermission(rn, "exam_prep.practices.create") || h.rbacSvc.HasPermission(rn, "exam_prep.practices.update")

View File

@ -2,6 +2,7 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/chapa"
subscriptionsvc "Yimaru-Backend/internal/services/subscriptions" subscriptionsvc "Yimaru-Backend/internal/services/subscriptions"
"context" "context"
"encoding/json" "encoding/json"
@ -381,14 +382,16 @@ func (h *Handler) SubscribeWithPayment(c *fiber.Ctx) error {
} }
// Use ArifPay service to initiate payment // Use ArifPay service to initiate payment
result, err := h.arifpaySvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
PlanID: req.PlanID, PlanID: req.PlanID,
Phone: req.Phone, Phone: req.Phone,
Email: req.Email, Email: req.Email,
}) })
if err != nil { if err != nil {
status := fiber.StatusInternalServerError status := fiber.StatusInternalServerError
if err.Error() == "user already has an active subscription" { if errors.Is(err, chapa.ErrChapaNotConfigured) {
status = fiber.StatusServiceUnavailable
} else if err.Error() == "user already has an active subscription" {
status = fiber.StatusConflict status = fiber.StatusConflict
} else if err.Error() == "subscription plan is not active" { } else if err.Error() == "subscription plan is not active" {
status = fiber.StatusBadRequest status = fiber.StatusBadRequest

View File

@ -109,6 +109,11 @@ func (h *Handler) TeamMemberLogin(c *fiber.Ctx) error {
Message: "Failed to login", Message: "Failed to login",
Error: "Account is not active", Error: "Account is not active",
}) })
case errors.Is(err, domain.ErrTeamMemberPendingInvitation):
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Failed to login",
Error: "Please accept your invitation email and set your password before signing in",
})
default: default:
h.mongoLoggerSvc.Error("Team member login failed", h.mongoLoggerSvc.Error("Team member login failed",
zap.Int("status_code", fiber.StatusInternalServerError), zap.Int("status_code", fiber.StatusInternalServerError),

View File

@ -0,0 +1,296 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
type teamInvitationListItem struct {
ID int64 `json:"id"`
TeamMemberID int64 `json:"team_member_id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
TeamRole string `json:"team_role"`
Status string `json:"status"`
ExpiresAt string `json:"expires_at"`
CreatedAt string `json:"created_at"`
}
type listTeamInvitationsRes struct {
Invitations []teamInvitationListItem `json:"invitations"`
TotalCount int64 `json:"total_count"`
}
func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitationListItem {
return teamInvitationListItem{
ID: row.ID,
TeamMemberID: row.TeamMemberID,
Email: row.Email,
FirstName: row.FirstName,
LastName: row.LastName,
TeamRole: string(row.TeamRole),
Status: string(row.Status),
ExpiresAt: row.ExpiresAt.Format(time.RFC3339),
CreatedAt: row.CreatedAt.Format(time.RFC3339),
}
}
// InviteTeamMember godoc
// @Summary Invite a team member by email
// @Description Creates a pending team member and sends an invitation email with a setup link
// @Tags team
// @Accept json
// @Produce json
// @Param body body domain.InviteTeamMemberReq true "Invite payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/team/members/invite [post]
func (h *Handler) InviteTeamMember(c *fiber.Ctx) error {
var req domain.InviteTeamMemberReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
inviterID, _ := c.Locals("user_id").(int64)
var invitedBy *int64
if inviterID > 0 {
invitedBy = &inviterID
}
res, err := h.teamSvc.InviteTeamMember(c.Context(), req, invitedBy)
if err != nil {
return h.teamInvitationError(c, err, "Failed to send team invitation")
}
actorRole := ""
if role, ok := c.Locals("role").(domain.Role); ok {
actorRole = string(role)
}
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"email": res.Email, "team_member_id": res.TeamMemberID})
go h.activityLogSvc.RecordAction(context.Background(), invitedBy, &actorRole, domain.ActionTeamMemberInvited, domain.ResourceTeamMember, &res.TeamMemberID, "Invited team member: "+res.Email, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Team invitation sent successfully",
Data: res,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ResendTeamInvitation godoc
// @Summary Resend team invitation
// @Description Revokes the current pending invite and sends a new invitation email
// @Tags team
// @Produce json
// @Param id path int true "Team member ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/team/members/{id}/resend-invite [post]
func (h *Handler) ResendTeamInvitation(c *fiber.Ctx) error {
memberID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || memberID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid team member ID",
})
}
inviterID, _ := c.Locals("user_id").(int64)
var invitedBy *int64
if inviterID > 0 {
invitedBy = &inviterID
}
res, err := h.teamSvc.ResendTeamInvitation(c.Context(), memberID, invitedBy)
if err != nil {
return h.teamInvitationError(c, err, "Failed to resend team invitation")
}
return c.JSON(domain.Response{
Message: "Team invitation resent successfully",
Data: res,
Success: true,
})
}
// ListTeamInvitations godoc
// @Summary List team invitations
// @Description Lists team member invitations with optional status filter
// @Tags team
// @Produce json
// @Param status query string false "pending, accepted, expired, or revoked"
// @Param limit query int false "Limit (default 20)"
// @Param offset query int false "Offset (default 0)"
// @Success 200 {object} domain.Response
// @Router /api/v1/team/invitations [get]
func (h *Handler) ListTeamInvitations(c *fiber.Ctx) error {
status := strings.TrimSpace(c.Query("status"))
var statusPtr *string
if status != "" {
statusPtr = &status
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
rows, total, err := h.teamSvc.ListTeamInvitations(c.Context(), statusPtr, int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to list team invitations",
Error: err.Error(),
})
}
out := make([]teamInvitationListItem, 0, len(rows))
for _, row := range rows {
out = append(out, mapTeamInvitationListItem(row))
}
return c.JSON(domain.Response{
Message: "Team invitations retrieved successfully",
Data: listTeamInvitationsRes{
Invitations: out,
TotalCount: total,
},
Success: true,
})
}
// RevokeTeamInvitation godoc
// @Summary Revoke a pending team invitation
// @Description Revokes the invitation and removes the pending team member if not yet accepted
// @Tags team
// @Produce json
// @Param id path int true "Invitation ID"
// @Success 200 {object} domain.Response
// @Router /api/v1/team/invitations/{id}/revoke [post]
func (h *Handler) RevokeTeamInvitation(c *fiber.Ctx) error {
invitationID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || invitationID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid invitation ID",
})
}
if err := h.teamSvc.RevokeTeamInvitation(c.Context(), invitationID); err != nil {
return h.teamInvitationError(c, err, "Failed to revoke team invitation")
}
return c.JSON(domain.Response{
Message: "Team invitation revoked successfully",
Data: fiber.Map{"id": invitationID},
Success: true,
})
}
// VerifyTeamInvitation godoc
// @Summary Verify team invitation token
// @Description Public endpoint used by the admin panel accept-invite page
// @Tags team
// @Produce json
// @Param token query string true "Invitation token"
// @Success 200 {object} domain.Response
// @Router /api/v1/team/invitations/verify [get]
func (h *Handler) VerifyTeamInvitation(c *fiber.Ctx) error {
token := strings.TrimSpace(c.Query("token"))
if token == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invitation token is required",
})
}
res, err := h.teamSvc.VerifyTeamInvitation(c.Context(), token)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to verify invitation",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Invitation verification completed",
Data: res,
Success: true,
})
}
// AcceptTeamInvitation godoc
// @Summary Accept team invitation and set password
// @Description Public endpoint to activate a team member account after following the invite link
// @Tags team
// @Accept json
// @Produce json
// @Param body body domain.AcceptTeamInvitationReq true "Accept invitation payload"
// @Success 200 {object} domain.Response
// @Router /api/v1/team/invitations/accept [post]
func (h *Handler) AcceptTeamInvitation(c *fiber.Ctx) error {
var req domain.AcceptTeamInvitationReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
member, err := h.teamSvc.AcceptTeamInvitation(c.Context(), req)
if err != nil {
return h.teamInvitationError(c, err, "Failed to accept team invitation")
}
return c.JSON(domain.Response{
Message: "Team account activated successfully. You can now sign in.",
Data: toTeamMemberResponse(&member),
Success: true,
})
}
func (h *Handler) teamInvitationError(c *fiber.Ctx, err error, message string) error {
switch {
case errors.Is(err, domain.ErrTeamMemberEmailExists):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Email already exists"})
case errors.Is(err, domain.ErrInvalidTeamRole), errors.Is(err, domain.ErrInvalidEmploymentType):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: err.Error()})
case errors.Is(err, domain.ErrTeamInviteBaseURLNotConfigured):
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: message, Error: "TEAM_INVITE_BASE_URL is not configured"})
case errors.Is(err, domain.ErrTeamInvitationNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: message, Error: "Invitation not found"})
case errors.Is(err, domain.ErrTeamInvitationExpired):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Invitation has expired"})
case errors.Is(err, domain.ErrTeamInvitationAlreadyUsed):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Invitation has already been accepted"})
case errors.Is(err, domain.ErrTeamInvitationRevoked):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Invitation has been revoked"})
case errors.Is(err, domain.ErrTeamMemberNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: message, Error: "Team member not found"})
default:
h.mongoLoggerSvc.Error(message, zap.Error(err))
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: message, Error: err.Error()})
}
}

View File

@ -16,6 +16,8 @@ func (a *App) initAppRoutes() {
a.assessmentSvc, a.assessmentSvc,
a.questionsSvc, a.questionsSvc,
a.faqSvc, a.faqSvc,
a.emailTemplateSvc,
a.personaSvc,
a.examPrepSvc, a.examPrepSvc,
a.programSvc, a.programSvc,
a.courseSvc, a.courseSvc,
@ -25,6 +27,7 @@ func (a *App) initAppRoutes() {
a.practiceSvc, a.practiceSvc,
a.subscriptionsSvc, a.subscriptionsSvc,
a.arifpaySvc, a.arifpaySvc,
a.chapaSvc,
a.logger, a.logger,
a.settingSvc, a.settingSvc,
a.NotidicationStore, a.NotidicationStore,
@ -151,6 +154,13 @@ func (a *App) initAppRoutes() {
groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice) groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice)
groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice) groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
// LMS personas (catalog referenced by persona_id on practices)
groupV1.Get("/personas", a.authMiddleware, a.RequirePermission("personas.list"), h.ListPersonas)
groupV1.Post("/personas", a.authMiddleware, a.RequirePermission("personas.create"), h.CreatePersona)
groupV1.Get("/personas/:id", a.authMiddleware, a.RequirePermission("personas.get"), h.GetPersona)
groupV1.Put("/personas/:id", a.authMiddleware, a.RequirePermission("personas.update"), h.UpdatePersona)
groupV1.Delete("/personas/:id", a.authMiddleware, a.RequirePermission("personas.delete"), h.DeletePersona)
// File storage (MinIO) // File storage (MinIO)
groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL) groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL)
groupV1.Post("/files/refresh-url", a.authMiddleware, h.RefreshFileURL) groupV1.Post("/files/refresh-url", a.authMiddleware, h.RefreshFileURL)
@ -187,6 +197,16 @@ func (a *App) initAppRoutes() {
groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ) groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ)
groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ) groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ)
// Email templates
groupV1.Get("/admin/email-templates", a.authMiddleware, a.RequirePermission("email_templates.list"), h.ListEmailTemplatesAdmin)
groupV1.Get("/admin/email-templates/slug/:slug", a.authMiddleware, a.RequirePermission("email_templates.get"), h.GetEmailTemplateBySlugAdmin)
groupV1.Post("/admin/email-templates/slug/:slug/preview", a.authMiddleware, a.RequirePermission("email_templates.preview"), h.PreviewEmailTemplateBySlug)
groupV1.Get("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.get"), h.GetEmailTemplateByIDAdmin)
groupV1.Post("/admin/email-templates/:id/preview", a.authMiddleware, a.RequirePermission("email_templates.preview"), h.PreviewEmailTemplateByID)
groupV1.Post("/admin/email-templates", a.authMiddleware, a.RequirePermission("email_templates.create"), h.CreateEmailTemplate)
groupV1.Put("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.update"), h.UpdateEmailTemplate)
groupV1.Delete("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.delete"), h.DeleteEmailTemplate)
// Question Sets // Question Sets
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet) groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType) groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
@ -223,14 +243,15 @@ func (a *App) initAppRoutes() {
groupV1.Post("/subscriptions/:id/cancel", a.authMiddleware, a.RequirePermission("subscriptions.cancel"), h.CancelSubscription) groupV1.Post("/subscriptions/:id/cancel", a.authMiddleware, a.RequirePermission("subscriptions.cancel"), h.CancelSubscription)
groupV1.Put("/subscriptions/:id/auto-renew", a.authMiddleware, a.RequirePermission("subscriptions.set_auto_renew"), h.SetAutoRenew) groupV1.Put("/subscriptions/:id/auto-renew", a.authMiddleware, a.RequirePermission("subscriptions.set_auto_renew"), h.SetAutoRenew)
// Payments (ArifPay) // Payments (Chapa)
groupV1.Post("/payments/subscribe", a.authMiddleware, a.RequirePermission("payments.initiate"), h.InitiateSubscriptionPayment) groupV1.Post("/payments/subscribe", a.authMiddleware, a.RequirePermission("payments.initiate"), h.InitiateSubscriptionPayment)
groupV1.Get("/payments/verify/:session_id", a.authMiddleware, a.RequirePermission("payments.verify"), h.VerifyPayment) groupV1.Get("/payments/verify/:session_id", a.authMiddleware, a.RequirePermission("payments.verify"), h.VerifyPayment)
groupV1.Get("/payments", a.authMiddleware, a.RequirePermission("payments.list_mine"), h.GetMyPayments) groupV1.Get("/payments", a.authMiddleware, a.RequirePermission("payments.list_mine"), h.GetMyPayments)
groupV1.Get("/payments/methods", h.GetArifpayPaymentMethods) groupV1.Get("/payments/methods", h.GetChapaPaymentMethods)
groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID) groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID)
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment) groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
groupV1.Post("/payments/webhook", h.HandleArifpayWebhook) groupV1.Post("/payments/webhook", h.HandleChapaWebhook)
groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback)
// Direct Payments // Direct Payments
groupV1.Post("/payments/direct", a.authMiddleware, a.RequirePermission("payments.direct_initiate"), h.InitiateDirectPayment) groupV1.Post("/payments/direct", a.authMiddleware, a.RequirePermission("payments.direct_initiate"), h.InitiateDirectPayment)
@ -375,15 +396,21 @@ func (a *App) initAppRoutes() {
teamGroup := groupV1.Group("/team") teamGroup := groupV1.Group("/team")
teamGroup.Post("/login", h.TeamMemberLogin) teamGroup.Post("/login", h.TeamMemberLogin)
teamGroup.Post("/refresh", h.TeamMemberRefresh) teamGroup.Post("/refresh", h.TeamMemberRefresh)
teamGroup.Get("/invitations/verify", h.VerifyTeamInvitation)
teamGroup.Post("/invitations/accept", h.AcceptTeamInvitation)
teamGroup.Get("/me", a.authMiddleware, a.RequirePermission("team.profile.get_mine"), h.GetMyTeamProfile) teamGroup.Get("/me", a.authMiddleware, a.RequirePermission("team.profile.get_mine"), h.GetMyTeamProfile)
teamGroup.Get("/stats", a.authMiddleware, a.RequirePermission("team.stats"), h.GetTeamMemberStats) teamGroup.Get("/stats", a.authMiddleware, a.RequirePermission("team.stats"), h.GetTeamMemberStats)
teamGroup.Get("/members", a.authMiddleware, a.RequirePermission("team.members.list"), h.GetAllTeamMembers) teamGroup.Get("/members", a.authMiddleware, a.RequirePermission("team.members.list"), h.GetAllTeamMembers)
teamGroup.Post("/members/invite", a.authMiddleware, a.RequirePermission("team.members.invite"), h.InviteTeamMember)
teamGroup.Post("/members", a.authMiddleware, a.RequirePermission("team.members.create"), h.CreateTeamMember) teamGroup.Post("/members", a.authMiddleware, a.RequirePermission("team.members.create"), h.CreateTeamMember)
teamGroup.Post("/members/:id/resend-invite", a.authMiddleware, a.RequirePermission("team.invitations.resend"), h.ResendTeamInvitation)
teamGroup.Get("/members/:id", a.authMiddleware, a.RequirePermission("team.members.get"), h.GetTeamMember) teamGroup.Get("/members/:id", a.authMiddleware, a.RequirePermission("team.members.get"), h.GetTeamMember)
teamGroup.Put("/members/:id", a.authMiddleware, a.RequirePermission("team.members.update"), h.UpdateTeamMember) teamGroup.Put("/members/:id", a.authMiddleware, a.RequirePermission("team.members.update"), h.UpdateTeamMember)
teamGroup.Patch("/members/:id/status", a.authMiddleware, a.RequirePermission("team.members.update_status"), h.UpdateTeamMemberStatus) teamGroup.Patch("/members/:id/status", a.authMiddleware, a.RequirePermission("team.members.update_status"), h.UpdateTeamMemberStatus)
teamGroup.Delete("/members/:id", a.authMiddleware, a.RequirePermission("team.members.delete"), h.DeleteTeamMember) teamGroup.Delete("/members/:id", a.authMiddleware, a.RequirePermission("team.members.delete"), h.DeleteTeamMember)
teamGroup.Post("/members/:id/change-password", a.authMiddleware, a.RequirePermission("team.members.change_password"), h.ChangeTeamMemberPassword) teamGroup.Post("/members/:id/change-password", a.authMiddleware, a.RequirePermission("team.members.change_password"), h.ChangeTeamMemberPassword)
teamGroup.Get("/invitations", a.authMiddleware, a.RequirePermission("team.invitations.list"), h.ListTeamInvitations)
teamGroup.Post("/invitations/:id/revoke", a.authMiddleware, a.RequirePermission("team.invitations.revoke"), h.RevokeTeamInvitation)
// Ratings // Ratings
groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating) groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)

View File

@ -0,0 +1,286 @@
{
"info": {
"_postman_id": "e2b904c1-a8d7-4132-90c9-4f6619c82b91",
"name": "LMS Personas - Catalog CRUD",
"description": "Regenerated against `/api/v1/personas`. Bearer auth; permissions: personas.list, personas.create, personas.get, personas.update, personas.delete.\n\nResponse persona objects include `profile_picture` and `gender` keys always (JSON `null` when unset). Practices reference catalog rows via `persona_id`.\n\nRun folder top-to-bottom: Create captures `persona_id`; Delete removes that row.\nOptional: set collection variable `persona_id` manually (e.g. `1`) for read-only tests without Create/Delete.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{access_token}}",
"type": "string"
}
]
},
"variable": [
{
"key": "base_url",
"value": "http://localhost:8080"
},
{
"key": "access_token",
"value": ""
},
{
"key": "persona_id",
"value": "1",
"type": "string"
}
],
"item": [
{
"name": "01 - Personas CRUD",
"item": [
{
"name": "Create persona",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"const body = pm.response.json();",
"pm.test(\"Persona in data\", function () {",
" pm.expect(body.success).to.be.true;",
" pm.expect(body.data.id).to.be.a(\"number\");",
" pm.expect(body.data).to.have.property(\"profile_picture\");",
" pm.expect(body.data).to.have.property(\"gender\");",
"});",
"pm.collectionVariables.set(\"persona_id\", String(body.data.id));"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Postman Coach\",\n \"description\": \"Smoke-test persona from Postman\",\n \"profile_picture\": \"https://cdn.example.com/personas/postman-coach.png\",\n \"gender\": \"female\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/personas",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"personas"
]
},
"description": "Maps to `domain.CreateLmsPersonaInput`: `name` required; others optional. Whitespace-only `gender` omitted on save."
},
"response": []
},
{
"name": "List personas (default paging)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"pm.test(\"List envelope\", function () {",
" pm.expect(body.data.personas).to.be.an(\"array\");",
" pm.expect(body.data.total_count).to.be.a(\"number\");",
" pm.expect(body.data.limit).to.be.a(\"number\");",
" pm.expect(body.data.offset).to.be.a(\"number\");",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "GET",
"url": {
"raw": "{{base_url}}/api/v1/personas?limit=20&offset=0",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"personas"
],
"query": [
{
"key": "active_only",
"value": "true",
"description": "Omit or true: only active. false: include inactive."
},
{
"key": "limit",
"value": "20",
"description": "Defaults to 20 if invalid omitted; capped at 200 server-side."
},
{
"key": "offset",
"value": "0"
}
]
},
"description": "Query `active_only` defaults server-side to true unless value is literally `false`."
},
"response": []
},
{
"name": "List personas (include inactive)",
"request": {
"method": "GET",
"url": {
"raw": "{{base_url}}/api/v1/personas?active_only=false&limit=50&offset=0",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"personas"
],
"query": [
{
"key": "active_only",
"value": "false"
},
{
"key": "limit",
"value": "50"
},
{
"key": "offset",
"value": "0"
}
]
}
},
"response": []
},
{
"name": "Get persona by ID",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"pm.test(\"Stable persona keys\", function () {",
" pm.expect(body.data.name).to.be.a(\"string\");",
" pm.expect(body.data).to.have.property(\"profile_picture\");",
" pm.expect(body.data).to.have.property(\"gender\");",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "GET",
"url": {
"raw": "{{base_url}}/api/v1/personas/{{persona_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"personas",
"{{persona_id}}"
]
}
},
"response": []
},
{
"name": "Update persona",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"pm.test(\"Updated persona\", function () {",
" pm.expect(body.data.profile_picture).to.include(\"alex-v2\");",
" pm.expect(body.data.gender).to.eql(\"neutral\");",
" pm.expect(body.data.name).to.include(\"updated\");",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Postman Coach (updated)\",\n \"profile_picture\": \"https://cdn.example.com/personas/alex-v2.png\",\n \"gender\": \"neutral\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/personas/{{persona_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"personas",
"{{persona_id}}"
]
},
"description": "`UpdateLmsPersonaInput`: all fields optional. `\"gender\": \"\"` clears gender. Empty `name` is rejected."
},
"response": []
},
{
"name": "Delete persona",
"request": {
"method": "DELETE",
"url": {
"raw": "{{base_url}}/api/v1/personas/{{persona_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"personas",
"{{persona_id}}"
]
},
"description": "Deletes catalog row; practices with this `persona_id` get it cleared (SET NULL)."
},
"response": []
}
]
}
]
}