Spaces:
Paused
Paused
Manus commited on
Commit ·
d9f0d1f
1
Parent(s): 76dcbf6
Deploy Bema Learn backend only
Browse files- Dockerfile +24 -0
- README.md +11 -10
- bemalearn/bemalearn.php +61 -0
- bemalearn/includes/class-bl-auth.php +72 -0
- bemalearn/includes/class-bl-courses-controller.php +119 -0
- bemalearn/includes/class-bl-earnings-controller.php +238 -0
- bemalearn/includes/class-bl-migrations.php +104 -0
- bemalearn/includes/class-bl-seeder.php +109 -0
- docker-entrypoint-space.sh +67 -0
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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
| 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"
|