Manus commited on
Commit
d9f0d1f
·
1 Parent(s): 76dcbf6

Deploy Bema Learn backend only

Browse files
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM wordpress:6-php8.3-apache
2
+
3
+ ENV DEBIAN_FRONTEND=noninteractive \
4
+ MARIADB_ROOT_PASSWORD=change-me-root \
5
+ WORDPRESS_DB_NAME=bemalearn \
6
+ WORDPRESS_DB_USER=bemalearn \
7
+ WORDPRESS_DB_PASSWORD=change-me-db \
8
+ WORDPRESS_DB_HOST=127.0.0.1 \
9
+ PORT=7860
10
+
11
+ RUN apt-get update \
12
+ && apt-get install -y --no-install-recommends mariadb-server mariadb-client curl ca-certificates \
13
+ && rm -rf /var/lib/apt/lists/* \
14
+ && curl -fsSL https://github.com/wp-cli/wp-cli/releases/latest/download/wp-cli.phar -o /usr/local/bin/wp \
15
+ && chmod +x /usr/local/bin/wp \
16
+ && a2enmod rewrite headers
17
+
18
+ COPY bemalearn /var/www/html/wp-content/plugins/bemalearn
19
+ COPY docker-entrypoint-space.sh /usr/local/bin/docker-entrypoint-space.sh
20
+ RUN chmod +x /usr/local/bin/docker-entrypoint-space.sh \
21
+ && chown -R www-data:www-data /var/www/html/wp-content/plugins/bemalearn
22
+
23
+ EXPOSE 7860
24
+ ENTRYPOINT ["/usr/local/bin/docker-entrypoint-space.sh"]
README.md CHANGED
@@ -1,10 +1,11 @@
1
- ---
2
- title: JRNET
3
- emoji: 🌖
4
- colorFrom: green
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
1
+ # Bema Learn backend
2
+
3
+ Backend-only deployment of the Bema Learn assessment API. The Space runs the WordPress plugin with an embedded MariaDB database; the Next.js frontend, Python helper, and assessment evidence are not included.
4
+
5
+ API base URL:
6
+
7
+ `/wp-json/bemalearn/v1`
8
+
9
+ Endpoints include public course listing/detail and authenticated login, earnings, and withdrawals. The seeded assessment accounts are `instructor@example.test` / `assessment123` and `learner@example.test` / `assessment123`.
10
+
11
+ For production use, set `MARIADB_ROOT_PASSWORD`, `WORDPRESS_DB_PASSWORD`, `WP_ADMIN_PASSWORD`, and `SPACE_HOST` as Space secrets/variables. The embedded database is intended for this deployment and is not a substitute for managed persistent storage.
bemalearn/bemalearn.php ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+ /**
3
+ * Plugin Name: Bema Learn Assessment API
4
+ * Description: A small course platform API. Assessment use only.
5
+ * Version: 1.0.0
6
+ * Requires PHP: 7.4
7
+ * Author: Bema Integrated Services Ltd
8
+ */
9
+
10
+ if (!defined('ABSPATH')) {
11
+ exit;
12
+ }
13
+
14
+ define('BEMALEARN_VERSION', '1.0.0');
15
+ define('BEMALEARN_PATH', plugin_dir_path(__FILE__));
16
+
17
+ require_once BEMALEARN_PATH . 'includes/class-bl-migrations.php';
18
+ require_once BEMALEARN_PATH . 'includes/class-bl-auth.php';
19
+ require_once BEMALEARN_PATH . 'includes/class-bl-courses-controller.php';
20
+ require_once BEMALEARN_PATH . 'includes/class-bl-earnings-controller.php';
21
+ require_once BEMALEARN_PATH . 'includes/class-bl-seeder.php';
22
+
23
+ register_activation_hook(__FILE__, ['BL_Migrations', 'run']);
24
+ register_activation_hook(__FILE__, ['BL_Seeder', 'seed']);
25
+
26
+ add_action('rest_api_init', function () {
27
+ (new BL_Courses_Controller())->register_routes();
28
+ (new BL_Earnings_Controller())->register_routes();
29
+ });
30
+
31
+ /**
32
+ * CORS for the assessment frontend on :3000.
33
+ *
34
+ * Kept deliberately simple. Task 4 does not concern this file.
35
+ */
36
+ add_action('init', function () {
37
+ $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
38
+ if (preg_match('#^http://localhost:\d+$#', $origin)) {
39
+ header('Access-Control-Allow-Origin: ' . $origin);
40
+ header('Access-Control-Allow-Credentials: true');
41
+ header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
42
+ header('Access-Control-Allow-Headers: Content-Type, Authorization, Idempotency-Key');
43
+ }
44
+ if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
45
+ status_header(200);
46
+ exit;
47
+ }
48
+ });
49
+
50
+ /**
51
+ * WP-CLI helper so the environment can be reset between attempts.
52
+ *
53
+ * wp bemalearn reset
54
+ */
55
+ if (defined('WP_CLI') && WP_CLI) {
56
+ WP_CLI::add_command('bemalearn reset', function () {
57
+ BL_Migrations::run();
58
+ BL_Seeder::seed(true);
59
+ WP_CLI::success('Bema Learn data reset.');
60
+ });
61
+ }
bemalearn/includes/class-bl-auth.php ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+ /**
3
+ * Minimal bearer-token auth for the assessment.
4
+ *
5
+ * Deliberately simple: a signed, expiring token stored in user meta. This is
6
+ * NOT a production auth design, and Task 4 does not concern this file.
7
+ */
8
+
9
+ if (!defined('ABSPATH')) {
10
+ exit;
11
+ }
12
+
13
+ class BL_Auth {
14
+
15
+ const META_KEY = 'bl_api_token';
16
+ const TTL = 86400;
17
+
18
+ public static function issue_token($user_id) {
19
+ $token = wp_generate_password(48, false, false);
20
+ update_user_meta((int) $user_id, self::META_KEY, [
21
+ 'hash' => hash('sha256', $token),
22
+ 'expires' => time() + self::TTL,
23
+ ]);
24
+ return $token;
25
+ }
26
+
27
+ /**
28
+ * Resolve the caller from the Authorization header.
29
+ * Returns a WP_User, or null when absent/invalid/expired.
30
+ */
31
+ public static function user_from_request($request) {
32
+ $header = '';
33
+ if (is_object($request) && method_exists($request, 'get_header')) {
34
+ $header = (string) $request->get_header('authorization');
35
+ }
36
+ if ($header === '' && isset($_SERVER['HTTP_AUTHORIZATION'])) {
37
+ $header = (string) $_SERVER['HTTP_AUTHORIZATION'];
38
+ }
39
+ if (stripos($header, 'Bearer ') !== 0) {
40
+ return null;
41
+ }
42
+
43
+ $token = trim(substr($header, 7));
44
+ if ($token === '') {
45
+ return null;
46
+ }
47
+
48
+ $hash = hash('sha256', $token);
49
+
50
+ $users = get_users([
51
+ 'meta_key' => self::META_KEY,
52
+ 'number' => 100,
53
+ 'fields' => 'ID',
54
+ ]);
55
+
56
+ foreach ($users as $uid) {
57
+ $stored = get_user_meta((int) $uid, self::META_KEY, true);
58
+ if (!is_array($stored) || empty($stored['hash'])) {
59
+ continue;
60
+ }
61
+ if (!hash_equals($stored['hash'], $hash)) {
62
+ continue;
63
+ }
64
+ if (!empty($stored['expires']) && $stored['expires'] < time()) {
65
+ return null;
66
+ }
67
+ return get_user_by('id', (int) $uid);
68
+ }
69
+
70
+ return null;
71
+ }
72
+ }
bemalearn/includes/class-bl-courses-controller.php ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+ /**
3
+ * Courses endpoints.
4
+ *
5
+ * GET /bemalearn/v1/courses public list
6
+ * GET /bemalearn/v1/courses/{id} public detail
7
+ *
8
+ * The authority on what these should return is docs/API-CONTRACT.md.
9
+ */
10
+
11
+ if (!defined('ABSPATH')) {
12
+ exit;
13
+ }
14
+
15
+ class BL_Courses_Controller {
16
+
17
+ const PREVIEW_TTL = 300;
18
+
19
+ public function register_routes() {
20
+ register_rest_route('bemalearn/v1', '/courses', [
21
+ 'methods' => 'GET',
22
+ 'callback' => [$this, 'get_courses'],
23
+ 'permission_callback' => '__return_true',
24
+ ]);
25
+
26
+ register_rest_route('bemalearn/v1', '/courses/(?P<id>\d+)', [
27
+ 'methods' => 'GET',
28
+ 'callback' => [$this, 'get_course'],
29
+ 'permission_callback' => '__return_true',
30
+ 'args' => [
31
+ 'id' => [
32
+ 'required' => true,
33
+ 'validate_callback' => function ($v) {
34
+ return is_numeric($v) && (int) $v > 0;
35
+ },
36
+ ],
37
+ ],
38
+ ]);
39
+ }
40
+
41
+ public function get_courses($request) {
42
+ global $wpdb;
43
+
44
+ $table = $wpdb->prefix . 'bl_courses';
45
+ $users = $wpdb->users;
46
+
47
+ $rows = $wpdb->get_results(
48
+ "SELECT c.id, c.title, c.price_minor, c.currency,
49
+ c.enrolment_count, c.average_rating,
50
+ c.is_published, c.published_at,
51
+ u.display_name AS instructor_name
52
+ FROM {$table} c
53
+ LEFT JOIN {$users} u ON u.ID = c.instructor_id
54
+ WHERE c.is_published = 1
55
+ ORDER BY c.published_at DESC, c.id DESC"
56
+ );
57
+
58
+ $courses = [];
59
+ foreach ((array) $rows as $row) {
60
+ $courses[] = $this->shape($row);
61
+ }
62
+
63
+ return new WP_REST_Response([
64
+ 'courses' => $courses,
65
+ 'previewExpiresInSeconds' => self::PREVIEW_TTL,
66
+ ], 200);
67
+ }
68
+
69
+ public function get_course($request) {
70
+ global $wpdb;
71
+
72
+ $id = (int) $request->get_param('id');
73
+ $table = $wpdb->prefix . 'bl_courses';
74
+ $users = $wpdb->users;
75
+
76
+ $row = $wpdb->get_row($wpdb->prepare(
77
+ "SELECT c.*, u.display_name AS instructor_name
78
+ FROM {$table} c
79
+ LEFT JOIN {$users} u ON u.ID = c.instructor_id
80
+ WHERE c.id = %d",
81
+ $id
82
+ ));
83
+
84
+ // An unpublished course is treated as absent. A 403 here would confirm
85
+ // that the course exists, which is itself a disclosure.
86
+ if (!$row || !(int) $row->is_published) {
87
+ return new WP_Error('not_found', 'Course not found.', ['status' => 404]);
88
+ }
89
+
90
+ $shaped = $this->shape($row);
91
+ $shaped['description'] = $row->description;
92
+ $shaped['lessonCount'] = (int) $row->lesson_count;
93
+
94
+ return new WP_REST_Response($shaped, 200);
95
+ }
96
+
97
+ /**
98
+ * Turn a database row into the contract shape.
99
+ *
100
+ * enrolmentCount and averageRating are nullable in the contract: null means
101
+ * "not yet counted", which is not the same as a measured zero. They are
102
+ * cast only when they carry a value.
103
+ */
104
+ private function shape($row) {
105
+ return [
106
+ 'id' => (int) $row->id,
107
+ 'title' => $row->title,
108
+ 'instructorName' => $row->instructor_name,
109
+ 'priceMinor' => (int) $row->price_minor,
110
+ 'currency' => $row->currency,
111
+ 'enrolmentCount' => $row->enrolment_count === null ? null : (int) $row->enrolment_count,
112
+ 'averageRating' => $row->average_rating === null ? null : (float) $row->average_rating,
113
+ 'publishedAt' => $row->published_at
114
+ ? gmdate('c', strtotime($row->published_at))
115
+ : null,
116
+ 'isPublished' => (bool) $row->is_published,
117
+ ];
118
+ }
119
+ }
bemalearn/includes/class-bl-earnings-controller.php ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+ /**
3
+ * Earnings and withdrawals.
4
+ *
5
+ * GET /bemalearn/v1/me/earnings instructor only
6
+ * POST /bemalearn/v1/me/withdrawals instructor only
7
+ *
8
+ * These endpoints move money. The authority on their behaviour is
9
+ * docs/API-CONTRACT.md.
10
+ */
11
+
12
+ if (!defined('ABSPATH')) {
13
+ exit;
14
+ }
15
+
16
+ class BL_Earnings_Controller {
17
+
18
+ const MINIMUM_WITHDRAWAL_MINOR = 50000;
19
+
20
+ public function register_routes() {
21
+ register_rest_route('bemalearn/v1', '/me/earnings', [
22
+ 'methods' => 'GET',
23
+ 'callback' => [$this, 'get_earnings'],
24
+ 'permission_callback' => [$this, 'check_instructor'],
25
+ ]);
26
+
27
+ register_rest_route('bemalearn/v1', '/me/withdrawals', [
28
+ 'methods' => 'POST',
29
+ 'callback' => [$this, 'create_withdrawal'],
30
+ 'permission_callback' => [$this, 'check_instructor'],
31
+ 'args' => [
32
+ 'amountMinor' => [
33
+ 'required' => true,
34
+ 'validate_callback' => function ($v) {
35
+ // Money is an integer in minor units, and a withdrawal
36
+ // is strictly positive. Rejects strings, floats and
37
+ // negatives with a 400 before anything reaches the DB.
38
+ if (!is_int($v) && !(is_string($v) && ctype_digit($v))) {
39
+ return new WP_Error(
40
+ 'rest_invalid_param',
41
+ 'amountMinor must be a positive integer in minor units.',
42
+ ['status' => 400]
43
+ );
44
+ }
45
+ return (int) $v > 0;
46
+ },
47
+ ],
48
+ 'payoutReference' => [
49
+ 'required' => true,
50
+ 'validate_callback' => function ($v) {
51
+ // Must fit the VARCHAR(64) idempotency column.
52
+ return is_string($v)
53
+ && $v !== ''
54
+ && strlen($v) <= 64
55
+ && preg_match('/^[A-Za-z0-9_-]+$/', $v) === 1;
56
+ },
57
+ 'sanitize_callback' => function ($v) {
58
+ return sanitize_text_field((string) $v);
59
+ },
60
+ ],
61
+ ],
62
+ ]);
63
+
64
+ register_rest_route('bemalearn/v1', '/auth/login', [
65
+ 'methods' => 'POST',
66
+ 'callback' => [$this, 'login'],
67
+ 'permission_callback' => '__return_true',
68
+ ]);
69
+ }
70
+
71
+ /**
72
+ * The caller is signed in. Says nothing about their role.
73
+ */
74
+ public function check_authenticated($request) {
75
+ $user = BL_Auth::user_from_request($request);
76
+
77
+ if (!$user) {
78
+ return new WP_Error('unauthenticated', 'Sign in to continue.', ['status' => 401]);
79
+ }
80
+
81
+ return true;
82
+ }
83
+
84
+ public function check_instructor($request) {
85
+ $user = BL_Auth::user_from_request($request);
86
+
87
+ if (!$user) {
88
+ return new WP_Error('unauthenticated', 'Sign in to continue.', ['status' => 401]);
89
+ }
90
+
91
+ if (!in_array('bl_instructor', (array) $user->roles, true)) {
92
+ return new WP_Error('forbidden', 'Instructors only.', ['status' => 403]);
93
+ }
94
+
95
+ return true;
96
+ }
97
+
98
+ public function login($request) {
99
+ $email = sanitize_email((string) $request->get_param('email'));
100
+ $password = (string) $request->get_param('password');
101
+
102
+ $user = get_user_by('email', $email);
103
+
104
+ if (!$user || !wp_check_password($password, $user->user_pass, $user->ID)) {
105
+ return new WP_Error(
106
+ 'invalid_credentials',
107
+ 'Email or password is incorrect.',
108
+ ['status' => 401]
109
+ );
110
+ }
111
+
112
+ return new WP_REST_Response([
113
+ 'token' => BL_Auth::issue_token($user->ID),
114
+ 'user' => [
115
+ 'id' => (int) $user->ID,
116
+ 'name' => $user->display_name,
117
+ 'role' => in_array('bl_instructor', (array) $user->roles, true)
118
+ ? 'instructor' : 'learner',
119
+ ],
120
+ ], 200);
121
+ }
122
+
123
+ public function get_earnings($request) {
124
+ global $wpdb;
125
+
126
+ $user = BL_Auth::user_from_request($request);
127
+ $ledger = $wpdb->prefix . 'bl_earnings_ledger';
128
+
129
+ $available = (int) $wpdb->get_var($wpdb->prepare(
130
+ "SELECT COALESCE(SUM(amount_minor), 0) FROM {$ledger}
131
+ WHERE instructor_id = %d
132
+ AND available_at IS NOT NULL
133
+ AND available_at <= UTC_TIMESTAMP()",
134
+ $user->ID
135
+ ));
136
+
137
+ $pending = (int) $wpdb->get_var($wpdb->prepare(
138
+ "SELECT COALESCE(SUM(amount_minor), 0) FROM {$ledger}
139
+ WHERE instructor_id = %d
140
+ AND (available_at IS NULL OR available_at > UTC_TIMESTAMP())",
141
+ $user->ID
142
+ ));
143
+
144
+ $last = $wpdb->get_var($wpdb->prepare(
145
+ "SELECT created_at FROM {$wpdb->prefix}bl_withdrawals
146
+ WHERE instructor_id = %d ORDER BY id DESC LIMIT 1",
147
+ $user->ID
148
+ ));
149
+
150
+ return new WP_REST_Response([
151
+ 'availableMinor' => $available,
152
+ 'pendingMinor' => $pending,
153
+ 'currency' => 'NGN',
154
+ 'minimumWithdrawalMinor' => self::MINIMUM_WITHDRAWAL_MINOR,
155
+ 'lastWithdrawalAt' => $last ? gmdate('c', strtotime($last)) : null,
156
+ ], 200);
157
+ }
158
+
159
+ public function create_withdrawal($request) {
160
+ global $wpdb;
161
+
162
+ $user = BL_Auth::user_from_request($request);
163
+ $amount = (int) $request->get_param('amountMinor');
164
+ $ref = (string) $request->get_param('payoutReference');
165
+
166
+ $table = $wpdb->prefix . 'bl_withdrawals';
167
+
168
+ if ($amount < self::MINIMUM_WITHDRAWAL_MINOR) {
169
+ return new WP_Error(
170
+ 'below_minimum',
171
+ 'The withdrawal amount is below the minimum allowed.',
172
+ ['status' => 422]
173
+ );
174
+ }
175
+
176
+ // Idempotency: a repeat of the same reference returns the original.
177
+ if ($ref) {
178
+ $existing = $wpdb->get_row($wpdb->prepare(
179
+ "SELECT * FROM {$table} WHERE instructor_id = %d AND payout_reference = %s",
180
+ $user->ID, $ref
181
+ ));
182
+ if ($existing) {
183
+ return new WP_REST_Response($this->shape($existing), 200);
184
+ }
185
+ }
186
+
187
+ $available = (int) $wpdb->get_var($wpdb->prepare(
188
+ "SELECT COALESCE(SUM(amount_minor), 0) FROM {$wpdb->prefix}bl_earnings_ledger
189
+ WHERE instructor_id = %d
190
+ AND available_at IS NOT NULL
191
+ AND available_at <= UTC_TIMESTAMP()",
192
+ $user->ID
193
+ ));
194
+
195
+ if ($amount > $available) {
196
+ return new WP_Error(
197
+ 'insufficient_balance',
198
+ 'Your available balance is lower than the requested amount.',
199
+ ['status' => 422]
200
+ );
201
+ }
202
+
203
+ $pending = (int) $wpdb->get_var($wpdb->prepare(
204
+ "SELECT COUNT(*) FROM {$table} WHERE instructor_id = %d AND status = 'pending'",
205
+ $user->ID
206
+ ));
207
+
208
+ if ($pending > 0) {
209
+ return new WP_Error(
210
+ 'withdrawal_in_progress',
211
+ 'You already have a withdrawal in progress.',
212
+ ['status' => 422]
213
+ );
214
+ }
215
+
216
+ $wpdb->insert($table, [
217
+ 'instructor_id' => $user->ID,
218
+ 'amount_minor' => $amount,
219
+ 'status' => 'pending',
220
+ 'payout_reference' => $ref,
221
+ 'created_at' => current_time('mysql', true),
222
+ ]);
223
+
224
+ $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $wpdb->insert_id));
225
+
226
+ return new WP_REST_Response($this->shape($row), 201);
227
+ }
228
+
229
+ private function shape($row) {
230
+ return [
231
+ 'id' => (int) $row->id,
232
+ 'amountMinor' => (int) $row->amount_minor,
233
+ 'status' => $row->status,
234
+ 'payoutReference' => $row->payout_reference,
235
+ 'createdAt' => gmdate('c', strtotime($row->created_at)),
236
+ ];
237
+ }
238
+ }
bemalearn/includes/class-bl-migrations.php ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+ /**
3
+ * Bema Learn schema.
4
+ *
5
+ * Forward-only. Each run is idempotent: creating a table that already exists is
6
+ * a no-op, so activating the plugin twice is safe.
7
+ *
8
+ * THIS FILE IS THE AUTHORITY ON WHAT COLUMNS EXIST.
9
+ * If code elsewhere reads a column, it must exist here.
10
+ */
11
+
12
+ if (!defined('ABSPATH')) {
13
+ exit;
14
+ }
15
+
16
+ class BL_Migrations {
17
+
18
+ public static function run() {
19
+ global $wpdb;
20
+
21
+ $charset = $wpdb->get_charset_collate();
22
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
23
+
24
+ $courses = $wpdb->prefix . 'bl_courses';
25
+ dbDelta("CREATE TABLE {$courses} (
26
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
27
+ title VARCHAR(191) NOT NULL,
28
+ description TEXT NULL,
29
+ instructor_id BIGINT UNSIGNED NOT NULL,
30
+ price_minor INT UNSIGNED NOT NULL DEFAULT 0,
31
+ currency VARCHAR(3) NOT NULL DEFAULT 'NGN',
32
+ lesson_count INT UNSIGNED NOT NULL DEFAULT 0,
33
+ enrolment_count INT UNSIGNED NULL DEFAULT NULL,
34
+ average_rating DECIMAL(3,2) NULL DEFAULT NULL,
35
+ is_published TINYINT(1) NOT NULL DEFAULT 0,
36
+ published_at DATETIME NULL DEFAULT NULL,
37
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
38
+ PRIMARY KEY (id),
39
+ KEY idx_instructor (instructor_id),
40
+ KEY idx_published (is_published)
41
+ ) {$charset};");
42
+
43
+ $enrolments = $wpdb->prefix . 'bl_enrolments';
44
+ dbDelta("CREATE TABLE {$enrolments} (
45
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
46
+ course_id BIGINT UNSIGNED NOT NULL,
47
+ learner_id BIGINT UNSIGNED NOT NULL,
48
+ amount_paid_minor INT UNSIGNED NOT NULL,
49
+ rating TINYINT UNSIGNED NULL DEFAULT NULL,
50
+ enrolled_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
51
+ refunded_at DATETIME NULL DEFAULT NULL,
52
+ PRIMARY KEY (id),
53
+ KEY idx_course (course_id),
54
+ KEY idx_learner (learner_id)
55
+ ) {$charset};");
56
+
57
+ /*
58
+ * Instructor earnings ledger. APPEND-ONLY.
59
+ *
60
+ * A reversal is a new row with a negative amount, never an edit or a
61
+ * delete of the original. The balance is the SUM of this table, so
62
+ * history stays auditable.
63
+ */
64
+ $ledger = $wpdb->prefix . 'bl_earnings_ledger';
65
+ dbDelta("CREATE TABLE {$ledger} (
66
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
67
+ instructor_id BIGINT UNSIGNED NOT NULL,
68
+ amount_minor INT NOT NULL,
69
+ entry_type VARCHAR(32) NOT NULL,
70
+ available_at DATETIME NULL DEFAULT NULL,
71
+ enrolment_id BIGINT UNSIGNED NULL DEFAULT NULL,
72
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
73
+ PRIMARY KEY (id),
74
+ KEY idx_instructor (instructor_id),
75
+ KEY idx_available (available_at)
76
+ ) {$charset};");
77
+
78
+ /*
79
+ * Withdrawals.
80
+ *
81
+ * payout_reference is the idempotency key: a retried request carrying
82
+ * the same reference must return the ORIGINAL row rather than create a
83
+ * second payout.
84
+ *
85
+ * NOTE FOR THE CANDIDATE (Task 5): look closely at the unique key
86
+ * below and satisfy yourself that it does what that paragraph claims.
87
+ */
88
+ $withdrawals = $wpdb->prefix . 'bl_withdrawals';
89
+ dbDelta("CREATE TABLE {$withdrawals} (
90
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
91
+ instructor_id BIGINT UNSIGNED NOT NULL,
92
+ amount_minor INT UNSIGNED NOT NULL,
93
+ status VARCHAR(32) NOT NULL DEFAULT 'pending',
94
+ payout_reference VARCHAR(64) NULL DEFAULT NULL,
95
+ cancelled_at DATETIME NULL DEFAULT NULL,
96
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
97
+ PRIMARY KEY (id),
98
+ UNIQUE KEY uq_reference (instructor_id, payout_reference, cancelled_at),
99
+ KEY idx_status (status)
100
+ ) {$charset};");
101
+
102
+ update_option('bemalearn_schema_version', BEMALEARN_VERSION);
103
+ }
104
+ }
bemalearn/includes/class-bl-seeder.php ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+ /**
3
+ * Seed data for the assessment.
4
+ *
5
+ * Deliberately shaped to exercise the contract's edge cases:
6
+ * - a course with NULL enrolment_count and NULL average_rating
7
+ * - a course with a REAL zero rating (0.00) - not the same thing
8
+ * - an UNPUBLISHED course, which must never appear in the public list
9
+ * - a ledger with both available and pending money
10
+ */
11
+
12
+ if (!defined('ABSPATH')) {
13
+ exit;
14
+ }
15
+
16
+ class BL_Seeder {
17
+
18
+ public static function seed($force = false) {
19
+ global $wpdb;
20
+
21
+ if (!$force && get_option('bemalearn_seeded')) {
22
+ return;
23
+ }
24
+
25
+ add_role('bl_instructor', 'Bema Learn Instructor', ['read' => true]);
26
+ add_role('bl_learner', 'Bema Learn Learner', ['read' => true]);
27
+
28
+ $instructor = self::ensure_user('instructor@example.test', 'Ada Okafor', 'bl_instructor');
29
+ $learner = self::ensure_user('learner@example.test', 'Bola Adeyemi', 'bl_learner');
30
+
31
+ $courses = $wpdb->prefix . 'bl_courses';
32
+ $wpdb->query("TRUNCATE TABLE {$courses}");
33
+
34
+ $rows = [
35
+ // title, price, lessons, enrolments, rating, published, published_at
36
+ ['Introduction to Bread Baking', 4500, 12, 128, 4.60, 1, '2026-03-14 08:00:00'],
37
+ ['Sourdough Starters', 6000, 18, 64, 4.20, 1, '2026-04-02 08:00:00'],
38
+ // NULL enrolments and NULL rating - "not yet counted", NOT zero
39
+ ['Pastry Fundamentals', 7500, 24, null, null, 1, '2026-05-20 08:00:00'],
40
+ // A REAL zero rating - must display differently from NULL
41
+ ['Cake Decorating Basics', 3000, 8, 9, 0.00, 1, '2026-06-11 08:00:00'],
42
+ // UNPUBLISHED - must never appear in the public list
43
+ ['Advanced Laminated Dough', 9000, 30, 0, null, 0, null],
44
+ ];
45
+
46
+ foreach ($rows as $r) {
47
+ $wpdb->insert($courses, [
48
+ 'title' => $r[0],
49
+ 'description' => $r[0] . ' - a short course for the assessment environment.',
50
+ 'instructor_id' => $instructor,
51
+ 'price_minor' => $r[1],
52
+ 'currency' => 'NGN',
53
+ 'lesson_count' => $r[2],
54
+ 'enrolment_count' => $r[3],
55
+ 'average_rating' => $r[4],
56
+ 'is_published' => $r[5],
57
+ 'published_at' => $r[6],
58
+ ]);
59
+ }
60
+
61
+ $ledger = $wpdb->prefix . 'bl_earnings_ledger';
62
+ $wpdb->query("TRUNCATE TABLE {$ledger}");
63
+
64
+ // Available now
65
+ foreach ([45000, 60000, 23500] as $amount) {
66
+ $wpdb->insert($ledger, [
67
+ 'instructor_id' => $instructor,
68
+ 'amount_minor' => $amount,
69
+ 'entry_type' => 'enrolment',
70
+ 'available_at' => gmdate('Y-m-d H:i:s', time() - 86400),
71
+ ]);
72
+ }
73
+
74
+ // Still pending (held)
75
+ $wpdb->insert($ledger, [
76
+ 'instructor_id' => $instructor,
77
+ 'amount_minor' => 45000,
78
+ 'entry_type' => 'enrolment',
79
+ 'available_at' => gmdate('Y-m-d H:i:s', time() + (86400 * 5)),
80
+ ]);
81
+
82
+ $wpdb->query("TRUNCATE TABLE {$wpdb->prefix}bl_withdrawals");
83
+ $wpdb->query("TRUNCATE TABLE {$wpdb->prefix}bl_enrolments");
84
+
85
+ // Enrolments, including one refunded and some unrated rows
86
+ $enr = $wpdb->prefix . 'bl_enrolments';
87
+ $wpdb->insert($enr, ['course_id' => 1, 'learner_id' => $learner, 'amount_paid_minor' => 4500, 'rating' => 5]);
88
+ $wpdb->insert($enr, ['course_id' => 1, 'learner_id' => $learner, 'amount_paid_minor' => 4500, 'rating' => null]);
89
+ $wpdb->insert($enr, ['course_id' => 2, 'learner_id' => $learner, 'amount_paid_minor' => 6000, 'rating' => 4,
90
+ 'refunded_at' => gmdate('Y-m-d H:i:s')]);
91
+
92
+ update_option('bemalearn_seeded', 1);
93
+ }
94
+
95
+ private static function ensure_user($email, $name, $role) {
96
+ $existing = get_user_by('email', $email);
97
+ if ($existing) {
98
+ return (int) $existing->ID;
99
+ }
100
+ $id = wp_insert_user([
101
+ 'user_login' => sanitize_user(strtok($email, '@')),
102
+ 'user_email' => $email,
103
+ 'user_pass' => 'assessment123',
104
+ 'display_name' => $name,
105
+ 'role' => $role,
106
+ ]);
107
+ return (int) $id;
108
+ }
109
+ }
docker-entrypoint-space.sh ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -Eeuo pipefail
3
+
4
+ : "${MARIADB_ROOT_PASSWORD:?MARIADB_ROOT_PASSWORD is required}"
5
+ : "${WORDPRESS_DB_NAME:?WORDPRESS_DB_NAME is required}"
6
+ : "${WORDPRESS_DB_USER:?WORDPRESS_DB_USER is required}"
7
+ : "${WORDPRESS_DB_PASSWORD:?WORDPRESS_DB_PASSWORD is required}"
8
+
9
+ mkdir -p /run/mysqld /var/lib/mysql
10
+ chown -R mysql:mysql /run/mysqld /var/lib/mysql
11
+
12
+ if [ ! -d /var/lib/mysql/mysql ]; then
13
+ mariadb-install-db --user=mysql --datadir=/var/lib/mysql >/dev/null
14
+ fi
15
+
16
+ mariadbd --user=mysql --bind-address=127.0.0.1 --skip-name-resolve &
17
+ DB_PID=$!
18
+ cleanup() { kill "$DB_PID" 2>/dev/null || true; }
19
+ trap cleanup EXIT
20
+
21
+ for _ in $(seq 1 60); do
22
+ mariadb-admin ping -h 127.0.0.1 --silent >/dev/null 2>&1 && break
23
+ sleep 1
24
+ done
25
+ mariadb-admin ping -h 127.0.0.1 --silent >/dev/null 2>&1 || { echo 'MariaDB failed to start' >&2; exit 1; }
26
+
27
+ mariadb -h 127.0.0.1 -uroot <<SQL
28
+ CREATE DATABASE IF NOT EXISTS \`${WORDPRESS_DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
29
+ CREATE USER IF NOT EXISTS '${WORDPRESS_DB_USER}'@'127.0.0.1' IDENTIFIED BY '${WORDPRESS_DB_PASSWORD}';
30
+ ALTER USER '${WORDPRESS_DB_USER}'@'127.0.0.1' IDENTIFIED BY '${WORDPRESS_DB_PASSWORD}';
31
+ GRANT ALL PRIVILEGES ON \`${WORDPRESS_DB_NAME}\`.* TO '${WORDPRESS_DB_USER}'@'127.0.0.1';
32
+ FLUSH PRIVILEGES;
33
+ SQL
34
+
35
+ cat > /var/www/html/wp-config-extra.php <<'PHP'
36
+ <?php
37
+ if (getenv('WP_ENVIRONMENT_TYPE')) {
38
+ define('WP_ENVIRONMENT_TYPE', getenv('WP_ENVIRONMENT_TYPE'));
39
+ }
40
+ define('WP_DEBUG', filter_var(getenv('WORDPRESS_DEBUG') ?: '0', FILTER_VALIDATE_BOOLEAN));
41
+ define('WP_DEBUG_LOG', true);
42
+ define('WP_DEBUG_DISPLAY', false);
43
+ PHP
44
+
45
+ # The official image generates wp-config.php from the standard environment variables.
46
+ export WORDPRESS_DB_HOST=127.0.0.1
47
+ export WORDPRESS_DB_NAME="$WORDPRESS_DB_NAME"
48
+ export WORDPRESS_DB_USER="$WORDPRESS_DB_USER"
49
+ export WORDPRESS_DB_PASSWORD="$WORDPRESS_DB_PASSWORD"
50
+
51
+ apache2-foreground &
52
+ APACHE_PID=$!
53
+ for _ in $(seq 1 90); do
54
+ if wp core is-installed --path=/var/www/html --allow-root >/dev/null 2>&1; then break; fi
55
+ if [ ! -f /var/www/html/wp-config.php ]; then
56
+ wp core download --path=/var/www/html --force --allow-root >/dev/null 2>&1 || true
57
+ wp config create --path=/var/www/html --dbname="$WORDPRESS_DB_NAME" --dbuser="$WORDPRESS_DB_USER" --dbpass="$WORDPRESS_DB_PASSWORD" --dbhost=127.0.0.1 --skip-check --force --allow-root >/dev/null 2>&1 || true
58
+ fi
59
+ wp core install --path=/var/www/html --url="${SPACE_HOST:-http://localhost:7860}" --title='Bema Learn API' --admin_user="${WP_ADMIN_USER:-admin}" --admin_password="${WP_ADMIN_PASSWORD:-change-me-admin}" --admin_email="${WP_ADMIN_EMAIL:-admin@example.test}" --skip-email --allow-root >/dev/null 2>&1 || true
60
+ sleep 2
61
+ done
62
+ wp core is-installed --path=/var/www/html --allow-root >/dev/null 2>&1 || { echo 'WordPress failed to initialize' >&2; exit 1; }
63
+ wp plugin activate bemalearn --path=/var/www/html --allow-root >/dev/null
64
+ wp rewrite structure '/%postname%/' --path=/var/www/html --allow-root >/dev/null 2>&1 || true
65
+ wp rewrite flush --hard --path=/var/www/html --allow-root >/dev/null 2>&1 || true
66
+
67
+ wait "$APACHE_PID"