Compare commits
16 Commits
7e61e34292
...
79851d31b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 79851d31b3 | |||
| 31bd1e3814 | |||
| 868e5ba001 | |||
| 5937c5505a | |||
| 1f7b38861e | |||
| de8618191c | |||
| f7c9eddef5 | |||
| 14d94ec723 | |||
| 5399d33af6 | |||
| 9ff418247f | |||
| 6ab077b53d | |||
| 9631711090 | |||
| 873be1b482 | |||
| 71bc09a638 | |||
| bd1767d2a6 | |||
| fffdff1031 |
33
cmd/main.go
33
cmd/main.go
|
|
@ -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,
|
||||||
|
|
|
||||||
3
db/migrations/000062_lesson_publish_status.down.sql
Normal file
3
db/migrations/000062_lesson_publish_status.down.sql
Normal 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;
|
||||||
9
db/migrations/000062_lesson_publish_status.up.sql
Normal file
9
db/migrations/000062_lesson_publish_status.up.sql
Normal 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';
|
||||||
21
db/migrations/000063_lms_personas.down.sql
Normal file
21
db/migrations/000063_lms_personas.down.sql
Normal 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;
|
||||||
64
db/migrations/000063_lms_personas.up.sql
Normal file
64
db/migrations/000063_lms_personas.up.sql
Normal 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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE lms_personas
|
||||||
|
RENAME COLUMN profile_picture TO avatar_url;
|
||||||
3
db/migrations/000064_lms_personas_profile_picture.up.sql
Normal file
3
db/migrations/000064_lms_personas_profile_picture.up.sql
Normal 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;
|
||||||
2
db/migrations/000065_lms_personas_gender.down.sql
Normal file
2
db/migrations/000065_lms_personas_gender.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE lms_personas
|
||||||
|
DROP COLUMN IF EXISTS gender;
|
||||||
2
db/migrations/000065_lms_personas_gender.up.sql
Normal file
2
db/migrations/000065_lms_personas_gender.up.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE lms_personas
|
||||||
|
ADD COLUMN gender TEXT;
|
||||||
1
db/migrations/000066_email_templates.down.sql
Normal file
1
db/migrations/000066_email_templates.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS email_templates;
|
||||||
186
db/migrations/000066_email_templates.up.sql
Normal file
186
db/migrations/000066_email_templates.up.sql
Normal 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;">© 2026 Yimaru Academy · 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’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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · All rights reserved</p>
|
||||||
|
</td></tr>
|
||||||
|
</table></td></tr></table></body></html>$custom_html$,
|
||||||
|
'["Subject", "Message"]'::jsonb,
|
||||||
|
TRUE,
|
||||||
|
'ACTIVE'
|
||||||
|
)
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
-- No-op: branded template content is not reverted automatically.
|
||||||
156
db/migrations/000067_branded_email_template_seeds.up.sql
Normal file
156
db/migrations/000067_branded_email_template_seeds.up.sql
Normal 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;">© 2026 Yimaru Academy · 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’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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · All rights reserved</p>
|
||||||
|
</td></tr>
|
||||||
|
</table></td></tr></table></body></html>$custom_html$,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE slug = 'custom_message';
|
||||||
1
db/migrations/000068_team_invitations.down.sql
Normal file
1
db/migrations/000068_team_invitations.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS team_invitations;
|
||||||
18
db/migrations/000068_team_invitations.up.sql
Normal file
18
db/migrations/000068_team_invitations.up.sql
Normal 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);
|
||||||
|
|
@ -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
|
||||||
*;
|
*;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -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
44
db/query/lms_personas.sql
Normal 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;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
80
db/query/team_invitations.sql
Normal file
80
db/query/team_invitations.sql
Normal 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
83
docs/CHAPA_INTEGRATION.md
Normal 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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
239
docs/docs.go
239
docs/docs.go
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -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
193
gen/db/lms_personas.sql.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
284
gen/db/team_invitations.sql.go
Normal file
284
gen/db/team_invitations.sql.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
67
internal/domain/chapa.go
Normal 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"`
|
||||||
|
}
|
||||||
58
internal/domain/email_template.go
Normal file
58
internal/domain/email_template.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
internal/domain/lms_persona.go
Normal file
39
internal/domain/lms_persona.go
Normal 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"`
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
79
internal/domain/team_invitation.go
Normal file
79
internal/domain/team_invitation.go
Normal 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"`
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
15
internal/ports/email_template.go
Normal file
15
internal/ports/email_template.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
internal/ports/lms_persona.go
Normal file
21
internal/ports/lms_persona.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
230
internal/repository/email_templates.go
Normal file
230
internal/repository/email_templates.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
119
internal/repository/lms_personas.go
Normal file
119
internal/repository/lms_personas.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
152
internal/repository/team_invitations.go
Normal file
152
internal/repository/team_invitations.go
Normal 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
|
||||||
|
}
|
||||||
472
internal/services/chapa/service.go
Normal file
472
internal/services/chapa/service.go
Normal 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
|
||||||
|
}
|
||||||
78
internal/services/emailtemplates/brand_templates.go
Normal file
78
internal/services/emailtemplates/brand_templates.go
Normal 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;">© 2026 Yimaru Academy · 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’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
|
||||||
|
)
|
||||||
112
internal/services/emailtemplates/defaults.go
Normal file
112
internal/services/emailtemplates/defaults.go
Normal 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
|
||||||
|
}
|
||||||
318
internal/services/emailtemplates/service.go
Normal file
318
internal/services/emailtemplates/service.go
Normal 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)
|
||||||
|
}
|
||||||
63
internal/services/emailtemplates/template_utils.go
Normal file
63
internal/services/emailtemplates/template_utils.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
95
internal/services/notification/fcm_credentials_normalize.go
Normal file
95
internal/services/notification/fcm_credentials_normalize.go
Normal 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:]
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
103
internal/services/personas/service.go
Normal file
103
internal/services/personas/service.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
327
internal/services/team/invite.go
Normal file
327
internal/services/team/invite.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
114
internal/web_server/handlers/chapa.go
Normal file
114
internal/web_server/handlers/chapa.go
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
467
internal/web_server/handlers/email_template.go
Normal file
467
internal/web_server/handlers/email_template.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
166
internal/web_server/handlers/lms_persona_handler.go
Normal file
166
internal/web_server/handlers/lms_persona_handler.go
Normal 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})
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
296
internal/web_server/handlers/team_invitation_handler.go
Normal file
296
internal/web_server/handlers/team_invitation_handler.go
Normal 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()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
286
postman/LMS-Personas.postman_collection.json
Normal file
286
postman/LMS-Personas.postman_collection.json
Normal 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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user