diff --git a/.gitattributes b/.gitattributes index 176a458..618a1d0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ * text=auto +*.sql linguist-language=SQL linguist-detectable=true +*.test.sql -linguist-language diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..d533e66 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,35 @@ +--- +name: Create Release + +permissions: + contents: write + +on: + push: + branches: + - 'main' + paths: + - 'database/**/*.sql' + +jobs: + suggest-bump: + uses: ./.github/workflows/suggest-version-bump.yml + + create-release: + runs-on: ubuntu-latest + needs: suggest-bump + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create release draft + uses: actions/create-release@v1 + with: + tag_name: ${{ needs.suggest-bump.outputs.next_version }} + release_name: ${{ needs.suggest-bump.outputs.next_version }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/get-pr-labels.yml b/.github/workflows/get-pr-labels.yml new file mode 100644 index 0000000..3cb4d49 --- /dev/null +++ b/.github/workflows/get-pr-labels.yml @@ -0,0 +1,35 @@ +--- +name: Get PR Labels + +permissions: + contents: read + statuses: read + pull-requests: read + +on: + workflow_call: + outputs: + labels: + description: 'Labels on the pull request' + value: ${{ jobs.get-labels.outputs.labels }} + +jobs: + get-labels: + name: Get PR Labels + runs-on: ubuntu-latest + outputs: + labels: ${{ steps.labels.outputs.labels }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get PR labels + id: labels + run: | + LABELS=$(gh pr view "${{ github.event.pull_request.number }}" --json labels --jq '[.labels[].name] | join(" ")') + echo "labels=$LABELS" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/suggest-version-bump.yml b/.github/workflows/suggest-version-bump.yml new file mode 100644 index 0000000..4eb20a2 --- /dev/null +++ b/.github/workflows/suggest-version-bump.yml @@ -0,0 +1,83 @@ +--- +name: Suggest Version Bump + +permissions: + contents: read + pull-requests: read + statuses: read + +on: + pull_request: + branches: + - main + paths: + - 'database/**/*.sql' + workflow_call: + outputs: + next_version: + description: 'The next suggested version' + value: ${{ jobs.suggest-bump.outputs.next_version }} + +jobs: + get-labels: + name: Get PR Labels + uses: ./.github/workflows/get-pr-labels.yml + + suggest-bump: + name: Suggest Version Bump + needs: get-labels + runs-on: ubuntu-latest + outputs: + next_version: ${{ steps.next_version.outputs.next_version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine bump type + id: bump + run: | + LABELS="${{ needs.get-labels.outputs.labels }}" + BUMP="patch" + echo "$LABELS" | grep -q 'type: feature' && BUMP="minor" + echo "$LABELS" | grep -q 'type: security' && BUMP="minor" + echo "$LABELS" | grep -q 'type: breaking' && BUMP="major" + echo "bump=$BUMP" >> "$GITHUB_OUTPUT" + + - name: Get latest tag + id: latest_tag + run: | + TAG=$(git tag --list 'v*' --sort=-v:refname | head -n1) + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Calculate next version + id: next_version + run: | + TAG="${{ steps.latest_tag.outputs.tag }}" + BUMP="${{ steps.bump.outputs.bump }}" + if [ -z "$TAG" ]; then + TAG="v0.0.0" + fi + VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/') + PRERELEASE=$(echo "$TAG" | sed -nE 's/^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$/\1/p') + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + case "$BUMP" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + if [ -n "$PRERELEASE" ]; then + NEXT_VERSION="v${MAJOR}.${MINOR}.${PATCH}${PRERELEASE}" + else + NEXT_VERSION="v${MAJOR}.${MINOR}.${PATCH}" + fi + echo "next_version=$NEXT_VERSION" >> "$GITHUB_OUTPUT" + + - name: Report summary + run: | + { + echo "### 🚀 Suggested Version Bump: **${{ steps.bump.outputs.bump }}**" + echo "#### Latest tag: \`${{ steps.latest_tag.outputs.tag }}\`" + echo "#### Next version: \`${{ steps.next_version.outputs.next_version }}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.vscode/settings.json b/.vscode/settings.json index 3b3e650..b408a93 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,6 +22,6 @@ } ], "triggerTaskOnSave.tasks": { - "Lint SQL File": ["**/*.sql"] + "Lint SQL File": ["database/**/*.sql"] } } diff --git a/database/functions/authenticate_user.sql b/database/functions/authenticate_user.sql new file mode 100644 index 0000000..af8534f --- /dev/null +++ b/database/functions/authenticate_user.sql @@ -0,0 +1,27 @@ +-- Authenticate a user and log the result +CREATE OR REPLACE FUNCTION authenticate_user( + p_username VARCHAR, + p_password_hash VARCHAR, + p_ip_address INET, + p_user_agent TEXT +) RETURNS BOOLEAN AS $$ +DECLARE + v_user_id INTEGER; +BEGIN + -- Attempt to find the user by username and hashed password + SELECT user_id INTO v_user_id + FROM users + WHERE username = p_username AND password_hash = p_password_hash + LIMIT 1; + + IF v_user_id IS NOT NULL THEN + -- Successful login: handle and log + CALL handle_successful_login(v_user_id, p_ip_address, p_user_agent); + RETURN TRUE; + ELSE + -- Failed login: log with NULL user_id + CALL log_login_attempt(NULL, p_ip_address, p_user_agent, FALSE); + RETURN FALSE; + END IF; +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/disable_2fa.sql b/database/functions/disable_2fa.sql new file mode 100644 index 0000000..0840da0 --- /dev/null +++ b/database/functions/disable_2fa.sql @@ -0,0 +1,8 @@ +-- Disable 2FA for a user +CREATE OR REPLACE FUNCTION disable_2fa(p_user_id INTEGER) RETURNS VOID AS $$ +BEGIN + UPDATE user_authentication_methods + SET is_enabled = FALSE, updated_at = NOW() + WHERE user_id = p_user_id; +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/get_user_2fa_secret.sql b/database/functions/get_user_2fa_secret.sql new file mode 100644 index 0000000..06d53b8 --- /dev/null +++ b/database/functions/get_user_2fa_secret.sql @@ -0,0 +1,9 @@ +-- Get the user's authentication methods secret +CREATE OR REPLACE FUNCTION get_user_authentication_method_secret(p_user_id INTEGER) RETURNS TABLE (method TEXT, secret TEXT) AS $$ +BEGIN + RETURN QUERY + SELECT authentication_method, user_authentication_method_secret + FROM user_authentication_methods + WHERE user_id = p_user_id AND is_enabled = TRUE; +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/is_2fa_enabled.sql b/database/functions/is_2fa_enabled.sql new file mode 100644 index 0000000..8188427 --- /dev/null +++ b/database/functions/is_2fa_enabled.sql @@ -0,0 +1,12 @@ +-- Check if the user has 2FA enabled +CREATE OR REPLACE FUNCTION is_2fa_enabled(p_user_id INTEGER) RETURNS BOOLEAN AS $$ +DECLARE + v_enabled BOOLEAN; +BEGIN + SELECT is_enabled INTO v_enabled + FROM user_authentication_methods + WHERE user_id = p_user_id; + + RETURN COALESCE(v_enabled, FALSE); +END; +$$ LANGUAGE plpgsql; diff --git a/database/procedures/authentication.sql b/database/procedures/authentication.sql deleted file mode 100644 index c228fa3..0000000 --- a/database/procedures/authentication.sql +++ /dev/null @@ -1,90 +0,0 @@ --- Log a login attempt (used in both success and failure cases) -CREATE OR REPLACE PROCEDURE log_login_attempt( - p_user_id INTEGER, - p_ip_address INET, - p_user_agent TEXT, - p_success BOOLEAN -) -LANGUAGE plpgsql AS $$ -BEGIN - INSERT INTO login_attempts (user_id, ip_address, user_agent, success) - VALUES (p_user_id, p_ip_address, p_user_agent, p_success); -END; -$$; - --- Update last_login and log successful attempt -CREATE OR REPLACE PROCEDURE handle_successful_login( - p_user_id INTEGER, - p_ip_address INET, - p_user_agent TEXT -) -AS $$ -BEGIN - -- Update login timestamp - UPDATE users SET last_login_at = NOW(), updated_at = NOW() - WHERE user_id = p_user_id; - - -- Log success - CALL log_login_attempt(p_user_id, p_ip_address, p_user_agent, TRUE); -END; -$$ LANGUAGE plpgsql; - --- Check if the user has 2FA enabled -CREATE OR REPLACE FUNCTION is_2fa_enabled(p_user_id INTEGER) RETURNS BOOLEAN AS $$ -DECLARE - v_enabled BOOLEAN; -BEGIN - SELECT is_enabled INTO v_enabled - FROM user_authentication_methods - WHERE user_id = p_user_id; - - RETURN COALESCE(v_enabled, FALSE); -END; -$$ LANGUAGE plpgsql; - --- Get the user's authentication methods secret -CREATE OR REPLACE FUNCTION get_user_authentication_method_secret(p_user_id INTEGER) RETURNS TABLE (method TEXT, secret TEXT) AS $$ -BEGIN - RETURN QUERY - SELECT authentication_method, user_authentication_method_secret - FROM user_authentication_methods - WHERE user_id = p_user_id AND is_enabled = TRUE; -END; -$$ LANGUAGE plpgsql; - --- Disable 2FA for a user -CREATE OR REPLACE FUNCTION disable_2fa(p_user_id INTEGER) RETURNS VOID AS $$ -BEGIN - UPDATE user_authentication_methods - SET is_enabled = FALSE, updated_at = NOW() - WHERE user_id = p_user_id; -END; -$$ LANGUAGE plpgsql; - --- Authenticate a user and log the result -CREATE OR REPLACE FUNCTION authenticate_user( - p_username VARCHAR, - p_password_hash VARCHAR, - p_ip_address INET, - p_user_agent TEXT -) RETURNS BOOLEAN AS $$ -DECLARE - v_user_id INTEGER; -BEGIN - -- Attempt to find the user by username and hashed password - SELECT user_id INTO v_user_id - FROM users - WHERE username = p_username AND password_hash = p_password_hash - LIMIT 1; - - IF v_user_id IS NOT NULL THEN - -- Successful login: handle and log - CALL handle_successful_login(v_user_id, p_ip_address, p_user_agent); - RETURN TRUE; - ELSE - -- Failed login: log with NULL user_id - CALL log_login_attempt(NULL, p_ip_address, p_user_agent, FALSE); - RETURN FALSE; - END IF; -END; -$$ LANGUAGE plpgsql; diff --git a/database/procedures/handle_successful_login.sql b/database/procedures/handle_successful_login.sql new file mode 100644 index 0000000..ffd2e8a --- /dev/null +++ b/database/procedures/handle_successful_login.sql @@ -0,0 +1,16 @@ +-- Update last_login and log successful attempt +CREATE OR REPLACE PROCEDURE handle_successful_login( + p_user_id INTEGER, + p_ip_address INET, + p_user_agent TEXT +) +AS $$ +BEGIN + -- Update login timestamp + UPDATE users SET last_login_at = NOW(), updated_at = NOW() + WHERE user_id = p_user_id; + + -- Log success + CALL log_login_attempt(p_user_id, p_ip_address, p_user_agent, TRUE); +END; +$$ LANGUAGE plpgsql; diff --git a/database/procedures/log_login_attempt.sql b/database/procedures/log_login_attempt.sql new file mode 100644 index 0000000..c8f7d07 --- /dev/null +++ b/database/procedures/log_login_attempt.sql @@ -0,0 +1,13 @@ +-- Log a login attempt (used in both success and failure cases) +CREATE OR REPLACE PROCEDURE log_login_attempt( + p_user_id INTEGER, + p_ip_address INET, + p_user_agent TEXT, + p_success BOOLEAN +) +LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO login_attempts (user_id, ip_address, user_agent, success) + VALUES (p_user_id, p_ip_address, p_user_agent, p_success); +END; +$$; diff --git a/database/procedures/register_user.sql b/database/procedures/register_user.sql new file mode 100644 index 0000000..38c4acc --- /dev/null +++ b/database/procedures/register_user.sql @@ -0,0 +1,46 @@ +-- Register a new user +CREATE OR REPLACE PROCEDURE register_user( + p_email_encrypted TEXT, + p_email_hash TEXT, + p_username VARCHAR, + p_password_hash VARCHAR, + p_phone_encrypted TEXT, + p_phone_hash TEXT, + p_preferred_language VARCHAR +) +LANGUAGE plpgsql AS $$ +BEGIN + BEGIN + INSERT INTO users ( + email_encrypted, + email_hash, + username, + password_hash, + phone_encrypted, + phone_hash, + preferred_language + ) + VALUES ( + p_email_encrypted, + p_email_hash, + p_username, + p_password_hash, + p_phone_encrypted, + p_phone_hash, + p_preferred_language + ); + EXCEPTION + WHEN unique_violation THEN + -- Identify the constraint that caused the error + IF SQLERRM LIKE '%username%' THEN + RAISE EXCEPTION 'Username % already exists', p_username; + ELSIF SQLERRM LIKE '%email_hash%' THEN + RAISE EXCEPTION 'Email address already exists'; + ELSIF SQLERRM LIKE '%phone_hash%' THEN + RAISE EXCEPTION 'Phone number already exists'; + ELSE + RAISE; + END IF; + END; +END; +$$; diff --git a/database/tests/authentication.test.sql b/database/tests/authentication.test.sql index 1270361..7b9772c 100644 --- a/database/tests/authentication.test.sql +++ b/database/tests/authentication.test.sql @@ -1,5 +1,7 @@ CREATE EXTENSION IF NOT EXISTS pgtap; +BEGIN; + SELECT plan(2); -- Clean environment @@ -34,6 +36,6 @@ SELECT is( 1, 'A successful login attempt is logged' ); --- Finish the tests and clean up. SELECT finish(TRUE); + ROLLBACK; diff --git a/database/tests/sample.test.sql b/database/tests/sample.test.sql index edb07a9..5986409 100644 --- a/database/tests/sample.test.sql +++ b/database/tests/sample.test.sql @@ -1,13 +1,13 @@ CREATE EXTENSION IF NOT EXISTS pgtap; +BEGIN; + SELECT plan(2); -BEGIN; -- Basic tests to ensure pgtap is working SELECT ok(TRUE, 'True is ok'); SELECT is(1 + 1, 2, '1 + 1 equals 2'); - --- Finish the tests and clean up. SELECT finish(TRUE); + ROLLBACK; diff --git a/database/tests/schema.test.sql b/database/tests/schema.test.sql new file mode 100644 index 0000000..27fc190 --- /dev/null +++ b/database/tests/schema.test.sql @@ -0,0 +1,66 @@ +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; + +SELECT plan(48); + +-- Verify that the schema has the expected types + +SELECT has_type('delivery_type', 'Type exists'); +SELECT has_type('order_status', 'Type exists'); +SELECT has_type('payment_status', 'Type exists'); +SELECT has_type('authentication_method', 'Type exists'); +SELECT has_type('moderation_action_type', 'Type exists'); +SELECT has_type('moderation_target_type', 'Type exists'); +SELECT has_type('discount_type', 'Type exists'); +SELECT has_type('promotion_type', 'Type exists'); +SELECT has_type('admin_account_role', 'Type exists'); +SELECT has_type('admin_action_target_type', 'Type exists'); + +-- Verify that the schema has the expected tables + +SELECT has_table('users', 'Table exists'); +SELECT has_table('user_permissions', 'Table exists'); +SELECT has_table('user_sessions', 'Table exists'); +SELECT has_table('email_verifications', 'Table exists'); +SELECT has_table('password_resets', 'Table exists'); +SELECT has_table('user_authentication_methods', 'Table exists'); +SELECT has_table('login_attempts', 'Table exists'); +SELECT has_table('products', 'Table exists'); +SELECT has_table('product_categories', 'Table exists'); +SELECT has_table('product_variants', 'Table exists'); +SELECT has_table('product_images', 'Table exists'); +SELECT has_table('product_likes', 'Table exists'); +SELECT has_table('product_comments', 'Table exists'); +SELECT has_table('moderation_actions', 'Table exists'); +SELECT has_table('carts', 'Table exists'); +SELECT has_table('cart_items', 'Table exists'); +SELECT has_table('orders', 'Table exists'); +SELECT has_table('order_items', 'Table exists'); +SELECT has_table('order_status_histories', 'Table exists'); +SELECT has_table('order_delivery_infos', 'Table exists'); +SELECT has_table('order_timestamps', 'Table exists'); +SELECT has_table('discount_codes', 'Table exists'); +SELECT has_table('user_discounts', 'Table exists'); +SELECT has_table('loyalty_programs', 'Table exists'); +SELECT has_table('user_loyalty_progress', 'Table exists'); +SELECT has_table('languages', 'Table exists'); +SELECT has_table('translations', 'Table exists'); +SELECT has_table('product_translations', 'Table exists'); +SELECT has_table('category_translations', 'Table exists'); +SELECT has_table('metrics_events', 'Table exists'); +SELECT has_table('admin_accounts', 'Table exists'); +SELECT has_table('admin_actions', 'Table exists'); +SELECT has_table('contact_messages', 'Table exists'); +SELECT has_table('feedbacks', 'Table exists'); + +-- Verify that the schema has the expected indexes + +SELECT has_index('users', 'idx_users_email_hash', 'Index exists'); +SELECT has_index('user_sessions', 'idx_user_sessions_token', 'Index exists'); +SELECT has_index('login_attempts', 'idx_login_attempts_user_id', 'Index exists'); +SELECT has_index('metrics_events', 'idx_metrics_events_event_type', 'Index exists'); + +SELECT finish(TRUE); + +ROLLBACK; diff --git a/database/tests/seed_data.test.sql b/database/tests/seed_data.test.sql new file mode 100644 index 0000000..fce97e2 --- /dev/null +++ b/database/tests/seed_data.test.sql @@ -0,0 +1,33 @@ +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; + +SELECT plan(3); + +SELECT is( + ( + SELECT count(*)::INT FROM languages + WHERE iso_code = 'fr' + ), + 1, 'Language with ISO code "fr" exists' +); + +SELECT is( + ( + SELECT count(*)::INT FROM languages + WHERE iso_code = 'en' + ), + 1, 'Language with ISO code "en" exists' +); + +SELECT is( + ( + SELECT count(*)::INT FROM languages + WHERE iso_code = 'es' + ), + 1, 'Language with ISO code "es" exists' +); + +SELECT finish(TRUE); + +ROLLBACK;