Skip to content

Commit aba8e36

Browse files
committed
implement referral system
1 parent 98d3148 commit aba8e36

18 files changed

Lines changed: 652 additions & 13 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Drop tables
2+
DROP TABLE IF EXISTS referral_rewards;
3+
DROP TABLE IF EXISTS referrals;
4+
DROP TABLE IF EXISTS app_configs;
5+
6+
-- Remove columns from users
7+
ALTER TABLE users DROP COLUMN IF EXISTS referral_link;
8+
ALTER TABLE users DROP COLUMN IF EXISTS referral_code;
9+
10+
-- Drop enums
11+
DROP TYPE IF EXISTS reward_type;
12+
DROP TYPE IF EXISTS referral_status;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
-- Create ENUM types for referral system
2+
CREATE TYPE referral_status AS ENUM ('pending', 'active', 'verified');
3+
CREATE TYPE reward_type AS ENUM ('earned', 'applied');
4+
5+
-- Modify users table
6+
ALTER TABLE users ADD COLUMN referral_code VARCHAR(50) UNIQUE;
7+
ALTER TABLE users ADD COLUMN referral_link TEXT;
8+
9+
-- Generate random referral codes for existing users (8 chars)
10+
UPDATE users SET referral_code = UPPER(SUBSTR(MD5(id::text), 1, 8)) WHERE referral_code IS NULL;
11+
12+
-- Make referral_code NOT NULL after backfilling
13+
ALTER TABLE users ALTER COLUMN referral_code SET NOT NULL;
14+
15+
-- Create app_configs table
16+
CREATE TABLE app_configs (
17+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
18+
key VARCHAR(100) UNIQUE NOT NULL,
19+
value TEXT NOT NULL,
20+
description TEXT,
21+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
22+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
23+
);
24+
25+
-- Seed default configs
26+
INSERT INTO app_configs (key, value, description) VALUES
27+
('reward_point', '100', 'The Naira equivalent of 1 reward point'),
28+
('referral_points_patient', '1', 'Points earned for referring a patient'),
29+
('referral_points_donor', '1', 'Points earned for referring a donor'),
30+
('referral_points_specialist', '1', 'Points earned for referring a specialist'),
31+
('referral_points_hospital', '1', 'Points earned for referring a hospital');
32+
33+
-- Create referrals table
34+
CREATE TABLE referrals (
35+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
36+
referrer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
37+
referred_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
38+
referral_code VARCHAR(50) NOT NULL, -- The code used during registration
39+
status referral_status NOT NULL DEFAULT 'pending',
40+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
41+
UNIQUE(referrer_id, referred_user_id)
42+
);
43+
44+
-- Create referral_rewards table
45+
CREATE TABLE referral_rewards (
46+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
47+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
48+
referral_id UUID REFERENCES referrals(id) ON DELETE SET NULL, -- Optional if not linked to a specific referral (e.g. bonus)
49+
points INT4 NOT NULL,
50+
reward_type reward_type NOT NULL,
51+
description TEXT,
52+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
53+
);
54+
55+
-- Indexes
56+
CREATE INDEX idx_users_referral_code ON users(referral_code);
57+
CREATE INDEX idx_referrals_referrer_id ON referrals(referrer_id);
58+
CREATE INDEX idx_referrals_referred_user_id ON referrals(referred_user_id);
59+
CREATE INDEX idx_referral_rewards_user_id ON referral_rewards(user_id);

src/admin/dtos.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,17 @@ pub enum AdminUserProfileResponse {
235235
#[serde(rename = "hospital")]
236236
Hospital(AdminHospitalProfileResponse),
237237
}
238+
239+
#[derive(Debug, Deserialize, ToSchema)]
240+
pub struct ConfigActionRequest {
241+
pub value: String,
242+
pub description: Option<String>,
243+
}
244+
245+
#[derive(Debug, Serialize, ToSchema)]
246+
pub struct ConfigResponse {
247+
pub key: String,
248+
pub value: String,
249+
pub description: Option<String>,
250+
pub updated_at: DateTime<Utc>,
251+
}

src/auth/service.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ pub fn create_user(
6868
}
6969

7070
// ---- Insert new user ----
71+
// I'll use random bytes for an 8-character hex code.
72+
let random_code = format!("{:08X}", OsRng.next_u32());
73+
7174
let new_user = NewUser {
7275
first_name: "",
7376
last_name: "",
@@ -80,6 +83,8 @@ pub fn create_user(
8083
password_hash: &hashed_password,
8184
role: user_role,
8285
consultation_preference: None,
86+
referral_code: &random_code,
87+
referral_link: None,
8388
};
8489

8590
let user = diesel::insert_into(users)

src/auth/socials.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pub fn login_or_register_social_user(
4141
Some(u) => u,
4242
None => {
4343
// 3. Create new user
44+
let referral_code = format!("{:08X}", rand::random::<u32>());
4445
let new_user = NewUser {
4546
first_name: profile.first_name.as_deref().unwrap_or(""),
4647
last_name: profile.last_name.as_deref().unwrap_or(""),
@@ -53,6 +54,8 @@ pub fn login_or_register_social_user(
5354
password_hash: "", // IMPORTANT: no password
5455
role: role.unwrap_or(Role::Patient),
5556
consultation_preference: None,
57+
referral_code: &referral_code,
58+
referral_link: None,
5659
};
5760

5861
diesel::insert_into(users::table)

src/bin/seed.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
6161
password_hash: &password_hash,
6262
role: Role::Patient,
6363
consultation_preference: None,
64+
referral_code: "PATIENT1",
65+
referral_link: None,
6466
};
6567

6668
diesel::insert_into(users::table)
@@ -109,6 +111,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
109111
password_hash: &password_hash,
110112
role: Role::Donor,
111113
consultation_preference: None,
114+
referral_code: "DONOR123",
115+
referral_link: None,
112116
};
113117

114118
diesel::insert_into(users::table)
@@ -157,6 +161,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
157161
password_hash: &password_hash,
158162
role: Role::Hospital,
159163
consultation_preference: None,
164+
referral_code: "HOSPITAL",
165+
referral_link: None,
160166
};
161167

162168
diesel::insert_into(users::table)
@@ -187,6 +193,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
187193
password_hash: &password_hash,
188194
role: Role::Admin,
189195
consultation_preference: None,
196+
referral_code: "ADMIN123",
197+
referral_link: None,
190198
};
191199

192200
diesel::insert_into(users::table)
@@ -217,6 +225,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
217225
password_hash: &password_hash,
218226
role: Role::Specialist,
219227
consultation_preference: None,
228+
referral_code: "DRSARAH1",
229+
referral_link: None,
220230
};
221231

222232
diesel::insert_into(users::table)

src/docs.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::handlers::{
2-
admin, appointments, auth, common, hospitals, notifications, patients, socials, specialists,
2+
admin, appointments, auth, common, hospitals, notifications, patients, referrals, socials, specialists,
33
};
44
use utoipa::OpenApi;
55
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
@@ -72,9 +72,29 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
7272
admin::get_users,
7373
admin::update_specialist_status,
7474
admin::update_hospital_status,
75+
admin::update_app_config,
76+
referrals::get_referral_summary,
77+
referrals::update_referral_link,
7578
),
7679
77-
components(),
80+
components(
81+
schemas(
82+
referrals::ReferralSummaryResponse,
83+
referrals::RewardHistoryResponse,
84+
referrals::ReferralStat,
85+
referrals::UpdateReferralLinkPayload,
86+
auth::RegisterRequest,
87+
auth::LoginRequest,
88+
auth::AuthResponse,
89+
auth::UserResponse,
90+
crate::admin::dtos::ConfigActionRequest,
91+
crate::admin::dtos::ConfigResponse,
92+
crate::utils::enums::ReferralStatusEnum,
93+
crate::utils::enums::RewardTypeEnum,
94+
crate::utils::enums::Role,
95+
crate::utils::enums::ConsultationTypeEnum,
96+
)
97+
),
7898
7999
modifiers(&SecurityAddon),
80100
tags(
@@ -86,6 +106,7 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
86106
(name = "notifications", description = "Notification management endpoints"),
87107
(name = "settings", description = "User settings endpoints"),
88108
(name = "admin", description = "Admin management endpoints"),
109+
(name = "referrals", description = "Referral system endpoints"),
89110
),
90111
info(
91112
title = "RubiMedik API",

src/handlers/admin.rs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ use crate::{
1010
},
1111
dashboard,
1212
dtos::{
13-
AdminDashboardResponse, HospitalActionRequest, SpecialistActionRequest, UserFilters,
14-
UserListResponse, AdminPatientProfileResponse, AdminUserProfileResponse,
13+
AdminDashboardResponse, ConfigActionRequest, ConfigResponse, HospitalActionRequest,
14+
SpecialistActionRequest, UserFilters, UserListResponse, AdminPatientProfileResponse,
15+
AdminUserProfileResponse,
1516
},
1617
},
1718
error::AppError,
@@ -278,3 +279,55 @@ pub async fn get_user_profile(
278279

279280
Ok(ApiResponse::success(response))
280281
}
282+
283+
/// Update application configuration (Admin only)
284+
///
285+
/// Allows admins to update system-wide settings like reward points and conversion rates
286+
#[utoipa::path(
287+
put,
288+
path = "/api/admin/configs/{key}",
289+
request_body = ConfigActionRequest,
290+
responses(
291+
(status = 200, body = ApiResponse<ConfigResponse>),
292+
(status = 401, description = "Unauthorized"),
293+
(status = 403, description = "Forbidden - Admin only"),
294+
(status = 404, description = "Config key not found"),
295+
(status = 500)
296+
),
297+
tag = "admin",
298+
security(("bearer_auth" = []))
299+
)]
300+
pub async fn update_app_config(
301+
State(state): State<AppState>,
302+
Extension(current_admin): Extension<User>,
303+
Path(key): Path<String>,
304+
Json(payload): Json<crate::admin::dtos::ConfigActionRequest>,
305+
) -> Result<ApiResponse<crate::admin::dtos::ConfigResponse>, AppError> {
306+
use crate::schema::app_configs;
307+
use diesel::prelude::*;
308+
309+
// Ensure user is admin
310+
if !matches!(current_admin.role, crate::utils::enums::Role::Admin) {
311+
return Err(AppError::Unauthorized(
312+
"Only administrators can perform this action".into(),
313+
));
314+
}
315+
316+
let mut conn = state.pool.get()?;
317+
318+
let updated_config = diesel::update(app_configs::table.filter(app_configs::key.eq(&key)))
319+
.set((
320+
app_configs::value.eq(payload.value),
321+
app_configs::description.eq(payload.description),
322+
app_configs::updated_at.eq(chrono::Utc::now()),
323+
))
324+
.get_result::<crate::models::AppConfig>(&mut conn)
325+
.map_err(|_| AppError::NotFound(format!("Config key '{}' not found", key)))?;
326+
327+
Ok(ApiResponse::success(crate::admin::dtos::ConfigResponse {
328+
key: updated_config.key,
329+
value: updated_config.value,
330+
description: updated_config.description,
331+
updated_at: updated_config.updated_at,
332+
}))
333+
}

src/handlers/auth.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ pub struct RegisterRequest {
2121
pub email: String,
2222
pub password: String,
2323
pub role: String,
24-
// pub phone: Option<String>,
24+
pub referred_by_code: Option<String>,
2525
}
2626

2727
#[derive(Deserialize, ToSchema)]
@@ -78,6 +78,8 @@ pub struct UserResponse {
7878
pub email: String,
7979
pub role: String,
8080
pub consultation_preference: Option<ConsultationTypeEnum>,
81+
pub referral_code: String,
82+
pub referral_link: Option<String>,
8183
}
8284

8385
#[derive(Serialize, ToSchema)]
@@ -95,6 +97,8 @@ impl From<User> for UserResponse {
9597
email: user.email,
9698
role: user.role.to_string(),
9799
consultation_preference: user.consultation_preference,
100+
referral_code: user.referral_code,
101+
referral_link: user.referral_link,
98102
}
99103
}
100104
}
@@ -155,7 +159,35 @@ pub async fn register(
155159
}
156160
}
157161

158-
let user = auth::create_user(&mut conn, &payload.email, &payload.password, other_role)?;
162+
let user = conn.transaction::<User, AppError, _>(|conn| {
163+
let new_user = auth::create_user(conn, &payload.email, &payload.password, other_role)?;
164+
165+
// Handle referral
166+
if let Some(ref_code) = &payload.referred_by_code {
167+
if let Some(referrer) = users
168+
.filter(crate::schema::users::referral_code.eq(ref_code))
169+
.first::<User>(conn)
170+
.optional()?
171+
{
172+
use crate::schema::referrals;
173+
use crate::models::NewReferral;
174+
use crate::utils::enums::ReferralStatusEnum;
175+
176+
let new_referral = NewReferral {
177+
referrer_id: referrer.id,
178+
referred_user_id: new_user.id,
179+
referral_code: ref_code.clone(),
180+
status: ReferralStatusEnum::Pending,
181+
};
182+
183+
diesel::insert_into(referrals::table)
184+
.values(&new_referral)
185+
.execute(conn)?;
186+
}
187+
}
188+
189+
Ok(new_user)
190+
})?;
159191

160192
let code = auth::create_email_verification_code(&mut conn, user.id, 4)?;
161193

src/handlers/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ pub mod appointments;
66
pub mod notifications;
77
pub mod socials;
88
pub mod common;
9-
pub mod admin;
9+
pub mod admin;
10+
pub mod referrals;

0 commit comments

Comments
 (0)