From 5d20c793eb3ac2537ac6ca815656240c3b2da03c Mon Sep 17 00:00:00 2001 From: FathurYur Date: Sat, 13 Jun 2026 13:18:32 +0700 Subject: [PATCH] initial commit --- .env.example | 29 + .gitignore | 9 + README.md | 141 ++ composer.json | 6 + composer.lock | 574 +++++ docs/erd.md | 76 + docs/openapi.js | 156 ++ docs/openapi.yaml | 208 ++ docs/swagger.html | 37 + get_jalan.php | 13 + get_lokasi.php | 14 + get_tanah.php | 15 + hapus_jalan.php | 9 + hapus_lokasi.php | 9 + hapus_tanah.php | 9 + index.html | 2337 +++++++++++++++++++ koneksi.php | 7 + login.html | 89 + scripts/apply_bantuan_terakhir.php | 47 + scripts/expire_assistance.php | 13 + scripts/run_migrations.php | 78 + scripts/simulate_expired.php | 30 + sql/migrations/001_add_bantuan_terakhir.sql | 12 + sql/migrations/001_init_schema.sql | 101 + sql/migrations/002_extend_lokasi.sql | 10 + sql/migrations/003_assistance_status.sql | 5 + sql/migrations/004_add_photo_path.sql | 3 + sql/migrations/005_seed_default_users.sql | 6 + sql/webgis.sql | 110 + src/api/admin/approve_user.php | 21 + src/api/admin/list_pending_managers.php | 17 + src/api/admin/reject_user.php | 21 + src/api/auth/approve_manager.php | 21 + src/api/auth/login.php | 43 + src/api/auth/logout.php | 7 + src/api/auth/mail_helper.php | 102 + src/api/auth/me.php | 8 + src/api/auth/pending_managers.php | 18 + src/api/auth/register.php | 113 + src/api/auth/reject_manager.php | 21 + src/api/auth/send_verification_code.php | 46 + src/api/geocode.php | 183 ++ src/api/get_client_config.php | 9 + src/api/get_lokasi.php | 80 + src/api/hapus_lokasi.php | 98 + src/api/tambah_lokasi.php | 194 ++ src/api/update_lokasi.php | 277 +++ src/api/upload_photo.php | 141 ++ src/config/auth.php | 275 +++ src/config/config.php | 49 + src/config/koneksi.php | 13 + tambah_jalan.php | 29 + tambah_lokasi.php | 24 + tambah_tanah.php | 32 + update_jalan.php | 23 + update_lokasi.php | 22 + update_tanah.php | 28 + uploads | 1 + webgis.sql | 110 + 59 files changed, 6179 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 docs/erd.md create mode 100644 docs/openapi.js create mode 100644 docs/openapi.yaml create mode 100644 docs/swagger.html create mode 100644 get_jalan.php create mode 100644 get_lokasi.php create mode 100644 get_tanah.php create mode 100644 hapus_jalan.php create mode 100644 hapus_lokasi.php create mode 100644 hapus_tanah.php create mode 100644 index.html create mode 100644 koneksi.php create mode 100644 login.html create mode 100644 scripts/apply_bantuan_terakhir.php create mode 100644 scripts/expire_assistance.php create mode 100644 scripts/run_migrations.php create mode 100644 scripts/simulate_expired.php create mode 100644 sql/migrations/001_add_bantuan_terakhir.sql create mode 100644 sql/migrations/001_init_schema.sql create mode 100644 sql/migrations/002_extend_lokasi.sql create mode 100644 sql/migrations/003_assistance_status.sql create mode 100644 sql/migrations/004_add_photo_path.sql create mode 100644 sql/migrations/005_seed_default_users.sql create mode 100644 sql/webgis.sql create mode 100644 src/api/admin/approve_user.php create mode 100644 src/api/admin/list_pending_managers.php create mode 100644 src/api/admin/reject_user.php create mode 100644 src/api/auth/approve_manager.php create mode 100644 src/api/auth/login.php create mode 100644 src/api/auth/logout.php create mode 100644 src/api/auth/mail_helper.php create mode 100644 src/api/auth/me.php create mode 100644 src/api/auth/pending_managers.php create mode 100644 src/api/auth/register.php create mode 100644 src/api/auth/reject_manager.php create mode 100644 src/api/auth/send_verification_code.php create mode 100644 src/api/geocode.php create mode 100644 src/api/get_client_config.php create mode 100644 src/api/get_lokasi.php create mode 100644 src/api/hapus_lokasi.php create mode 100644 src/api/tambah_lokasi.php create mode 100644 src/api/update_lokasi.php create mode 100644 src/api/upload_photo.php create mode 100644 src/config/auth.php create mode 100644 src/config/config.php create mode 100644 src/config/koneksi.php create mode 100644 tambah_jalan.php create mode 100644 tambah_lokasi.php create mode 100644 tambah_tanah.php create mode 100644 update_jalan.php create mode 100644 update_lokasi.php create mode 100644 update_tanah.php create mode 100644 uploads create mode 100644 webgis.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..045c892 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Example environment file for WebGIS. +# Copy this file to `.env`, then fill in real values. Do not commit `.env`. + +# Database +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASS=change_me +DB_NAME=webgis + +# Mail +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your_gmail_address@gmail.com +SMTP_PASS=your_gmail_app_password +SMTP_ENCRYPTION=tls +MAIL_FROM=your_gmail_address@gmail.com +MAIL_FROM_NAME=WebGIS + +# Auth secrets +# Generate secrets with: php -r "echo bin2hex(random_bytes(32)).PHP_EOL;" +API_KEY=change_me_to_a_random_hex_string +JWT_SECRET=change_me_to_a_random_hex_string +INTERNAL_AUTH_KEY=change_me_to_a_random_hex_string +BASIC_API_USER=webgis-api +BASIC_API_PASS=change_me + +# Optional local debug helper for verification codes. +DEBUG_MODE=0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b11003 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.env +/.env.local +/*.env +/uploads/ +/src/uploads/ +# ignore common editor temp files +*.swp +*.swo +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..809a707 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# WebGIS + +Aplikasi peta lokasi bantuan dan tempat ibadah berbasis PHP, MySQL, dan Leaflet. + +## Gambaran Singkat + +Guest bisa melihat peta dan daftar data. Pengelola mendaftar lewat formulir yang langsung memverifikasi email, lalu menunggu persetujuan admin. Setelah akun aktif, pengelola bisa menambah, mengubah, dan menghapus data miliknya sendiri. Admin bisa menyetujui atau menolak akun pengelola yang menunggu aktivasi. + +## Yang Dibutuhkan + +- PHP 8.1 atau lebih baru +- MySQL atau MariaDB +- Composer +- Browser modern seperti Chrome, Edge, atau Firefox +- Koneksi internet untuk Leaflet, tile peta, dan email SMTP + +## Langkah Instalasi + +Ikuti urutan ini dari awal jika Anda belum pernah menjalankan proyek ini. + +1. Salin folder proyek ke komputer Anda. +2. Buka terminal di folder proyek. +3. Pasang dependensi PHP: + +```bash +composer install +``` + +4. Buat file konfigurasi lingkungan: + +```bash +cp .env.example .env +``` + +5. Isi file `.env` dengan kredensial database, SMTP, dan secret aplikasi. +6. Buat database MySQL, misalnya bernama `webgis`. +7. Import skema database: + +```bash +mysql -u root -p webgis < sql/migrations/001_init_schema.sql +``` + +8. Jalankan server PHP bawaan untuk uji lokal: + +```bash +php -S localhost:8000 -t . +``` + +9. Buka aplikasi di browser: + +```text +http://localhost:8000/index.html +``` + +Jika Anda memakai Apache atau Nginx, arahkan document root ke folder proyek ini dan sesuaikan URL aksesnya. + +## Isi File `.env` + +Contoh nilai yang perlu diisi: + +- `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME` +- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_ENCRYPTION` +- `MAIL_FROM`, `MAIL_FROM_NAME` +- `API_KEY`, `JWT_SECRET`, `INTERNAL_AUTH_KEY` +- `BASIC_API_USER`, `BASIC_API_PASS` +- `DEBUG_MODE=0` untuk produksi atau `1` jika ingin menampilkan kode verifikasi saat pengembangan lokal + +## Cara Pakai + +### 1. Buka peta + +Setelah aplikasi terbuka, tampilan awal adalah peta. Guest tetap bisa melihat marker dan daftar data. + +### 2. Daftar pengelola + +Klik tombol masuk/daftar, lalu pilih daftar pengelola. Isi semua field yang diminta, kirim kode verifikasi ke email, masukkan kode 6 digit, lalu klik daftar. + +### 3. Login + +Gunakan email dan password yang sudah terdaftar. Setelah login, bar atas akan menampilkan nama pengguna. + +### 4. Kelola data + +Pengelola yang sudah aktif bisa menambah data baru dari peta, mengubah data miliknya sendiri, dan mengunggah foto bukti jika diperlukan. Admin bisa mengelola seluruh data. + +### 5. Lihat daftar data + +Klik tombol `Daftar Data` untuk melihat data dalam bentuk tabel. Guest hanya melihat data yang aman untuk publik. + +## Akun Admin Awal + +Seed database menyediakan akun admin awal: + +- Email: `admin@example.com` +- Password: `Admin123!` + +Segera ganti kredensial ini jika Anda menjalankan aplikasi di lingkungan yang tidak pribadi. + +## Endpoint Utama + +- `src/api/auth/register.php` - daftar pengelola +- `src/api/auth/send_verification_code.php` - kirim kode verifikasi email +- `src/api/auth/login.php` - login +- `src/api/auth/logout.php` - logout +- `src/api/auth/me.php` - status pengguna aktif +- `src/api/get_lokasi.php` - ambil data lokasi +- `src/api/tambah_lokasi.php` - tambah lokasi baru +- `src/api/update_lokasi.php` - ubah lokasi +- `src/api/hapus_lokasi.php` - hapus lokasi +- `src/api/upload_photo.php` - unggah foto lokasi +- `src/api/admin/list_pending_managers.php` - daftar akun pengelola menunggu persetujuan +- `src/api/admin/approve_user.php` - setujui akun +- `src/api/admin/reject_user.php` - tolak akun + +## File Penting + +- [index.html](index.html) - frontend utama +- [src/config/koneksi.php](src/config/koneksi.php) - koneksi database +- [src/config/config.php](src/config/config.php) - secret dan autentikasi +- [sql/migrations/001_init_schema.sql](sql/migrations/001_init_schema.sql) - skema database utama +- [webgis.sql](webgis.sql) - dump SQL yang sejalan dengan skema utama +- [.env.example](.env.example) - contoh konfigurasi environment + +## Perawatan Berkala + +Jika Anda ingin menandai bantuan yang sudah lewat masa berlakunya, jalankan: + +```bash +php scripts/expire_assistance.php +``` + +## Troubleshooting + +- Jika database gagal terhubung, cek isi `.env` dan pastikan MySQL berjalan. +- Jika email tidak terkirim, pastikan kredensial SMTP benar dan memakai app password, bukan password akun biasa. +- Jika peta kosong, cek koneksi internet dan pastikan endpoint `get_lokasi.php` mengembalikan JSON. +- Jika login berhasil tetapi data tidak muncul, buka DevTools browser dan cek respons API serta status token JWT. + +## Catatan Pengembangan + +Arsitektur aplikasi ini sengaja dibuat sederhana agar mudah dipelihara: frontend vanilla JavaScript, backend PHP, dan database MySQL. Bila Anda menambah kolom database baru, perbarui file migrasi, dump SQL, dan dokumentasi dalam satu perubahan agar semuanya tetap sinkron. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5e867e9 --- /dev/null +++ b/composer.json @@ -0,0 +1,6 @@ +{ + "require": { + "phpmailer/phpmailer": "^7.1", + "vlucas/phpdotenv": "^5.6" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..1fdc4ce --- /dev/null +++ b/composer.lock @@ -0,0 +1,574 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "44f3cb809a0f474d00b34192bc993c72", + "packages": [ + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "phpmailer/phpmailer", + "version": "v7.1.1", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15", + "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "squizlabs/php_codesniffer": "^3.13.5", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example", + "ext-imap": "Needed to support advanced email address parsing according to RFC822", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "support": { + "issues": "https://github.com/PHPMailer/PHPMailer/issues", + "source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2026-05-18T08:06:14+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.2.0" +} diff --git a/docs/erd.md b/docs/erd.md new file mode 100644 index 0000000..e7d75ee --- /dev/null +++ b/docs/erd.md @@ -0,0 +1,76 @@ +# ERD Summary - WebGIS + +Dokumen ini merangkum skema database yang dipakai aplikasi pada `sql/migrations/001_init_schema.sql` dan `webgis.sql`. + +## Tabel Utama + +### `users` + +Menyimpan akun pengguna aplikasi. + +Kolom penting: +- `email` +- `password_hash` +- `name` +- `organization` +- `org_address` +- `org_phone` +- `org_proof_path` +- `role` dengan nilai `manager` atau `admin` +- `status` dengan nilai `pending`, `active`, `rejected`, atau `pending_verification` +- `email_verified` +- field verifikasi email seperti `email_verification_token_hash`, `email_verification_expires`, dan timestamp terkait +- `approved_by` dan `approved_at` + +### `lokasi` + +Menyimpan seluruh marker yang tampil di peta. + +Kolom penting: +- `nama` +- `no_telp` +- `latitude` dan `longitude` +- `alamat` +- `jenis` +- field khusus rumah seperti `nama_kk`, `rumah_status`, `members`, `monthly_income` +- field bantuan seperti `assisted`, `last_assisted_at`, `assistance_notes`, `sumber_bantuan`, `bentuk_bantuan` +- `photo_path` +- `created_by` + +### `bantuan_detail` + +Menyimpan detail histori bantuan per lokasi. + +Kolom penting: +- `lokasi_id` +- `tanggal_bantuan` +- `pemberi_bantuan` +- `catatan` +- `pemberi_lokasi_id` +- `jumlah` + +### `migrations` + +Menyimpan catatan migration yang sudah dijalankan. + +## Relasi + +- `users.approved_by` mengarah ke `users.id` +- `lokasi.created_by` mengarah ke `users.id` +- `lokasi.sumber_bantuan` mengarah ke `lokasi.id` +- `bantuan_detail.lokasi_id` mengarah ke `lokasi.id` +- `bantuan_detail.pemberi_lokasi_id` mengarah ke `lokasi.id` + +## Alur Data + +1. Guest melihat data publik di peta dan tabel. +2. Pengelola mendaftar, menerima kode verifikasi email, lalu akun dibuat dalam status menunggu persetujuan admin. +3. Admin menyetujui akun pengelola. +4. Pengelola yang disetujui dapat menambah dan mengubah data miliknya. +5. Admin bisa mengelola seluruh data. + +## Catatan Implementasi + +- Database yang dipakai adalah MySQL/MariaDB. +- Semua relasi foreign key dibuat agar data tetap konsisten. +- Kolom bantuan dan verifikasi email disimpan langsung di tabel utama supaya alurnya sederhana untuk pengembangan berikutnya. diff --git a/docs/openapi.js b/docs/openapi.js new file mode 100644 index 0000000..f817b89 --- /dev/null +++ b/docs/openapi.js @@ -0,0 +1,156 @@ +window.webgisOpenApiSpec = { + openapi: '3.0.3', + info: { + title: 'WebGIS API', + version: '1.0.0', + description: 'API for public map data, manager registrations, and admin approvals.' + }, + servers: [{ url: '.' }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT' + } + } + }, + paths: { + '/src/api/auth/register.php': { + post: { + summary: 'Register manager account', + description: 'Public registration endpoint for manager accounts.', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['email', 'password'], + properties: { + email: { type: 'string', format: 'email' }, + password: { type: 'string' }, + name: { type: 'string' }, + organization: { type: 'string' } + } + } + } + } + }, + responses: { '200': { description: 'Registration successful, pending approval' } } + } + }, + '/src/api/auth/login.php': { + post: { + summary: 'Login', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['email', 'password'], + properties: { + email: { type: 'string', format: 'email' }, + password: { type: 'string' } + } + } + } + } + }, + responses: { '200': { description: 'Login successful' } } + } + }, + '/src/api/auth/logout.php': { + post: { + summary: 'Logout', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Logout successful' } } + } + }, + '/src/api/auth/me.php': { + get: { + summary: 'Current user session', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Current auth state' } } + } + }, + '/src/api/get_lokasi.php': { + get: { + summary: 'List locations', + description: 'Public read-only endpoint.', + responses: { '200': { description: 'Location list' } } + } + }, + '/src/api/tambah_lokasi.php': { + post: { + summary: 'Create location', + description: 'Bearer JWT required. Managers can create records; admins can create all.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + nama: { type: 'string' }, + jenis: { type: 'string' }, + alamat: { type: 'string' }, + latitude: { type: 'number' }, + longitude: { type: 'number' } + } + } + } + } + }, + responses: { '200': { description: 'Created' } } + } + }, + '/src/api/update_lokasi.php': { + post: { + summary: 'Update location', + description: 'Bearer JWT required. Managers can edit only their own lokasi; admins can edit all.', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Updated' } } + } + }, + '/src/api/hapus_lokasi.php': { + post: { + summary: 'Delete location', + description: 'Bearer JWT required. Managers can delete only their own lokasi; admins can delete all.', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Deleted' } } + } + }, + '/src/api/upload_photo.php': { + post: { + summary: 'Upload location photo', + description: 'Bearer JWT required. Managers can upload only for their own lokasi.', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Uploaded' } } + } + }, + '/src/api/admin/list_pending_managers.php': { + get: { + summary: 'List pending manager registrations', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Pending list' } } + } + }, + '/src/api/admin/approve_user.php': { + post: { + summary: 'Approve pending user', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Approved' } } + } + }, + '/src/api/admin/reject_user.php': { + post: { + summary: 'Reject pending user', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Rejected' } } + } + } + } +}; \ No newline at end of file diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..b3a42c2 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,208 @@ +openapi: 3.0.3 +info: + title: WebGIS API + version: 1.0.0 + description: API untuk autentikasi pengelola, data lokasi, unggah foto, dan persetujuan admin. +servers: + - url: . +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + basicAuth: + type: http + scheme: basic + schemas: + RegisterRequest: + type: object + required: + - email + - password + - name + - organization + - org_address + - org_phone + - verify_code + properties: + email: + type: string + format: email + password: + type: string + name: + type: string + organization: + type: string + org_address: + type: string + org_phone: + type: string + verify_code: + type: string + pattern: '^[0-9]{6}$' + LoginRequest: + type: object + required: + - email + - password + properties: + email: + type: string + format: email + password: + type: string + LocationRequest: + type: object + properties: + nama: + type: string + jenis: + type: string + no_telp: + type: string + alamat: + type: string + latitude: + type: number + longitude: + type: number + assisted: + type: integer + enum: [0, 1] +paths: + /src/api/auth/register.php: + post: + summary: Daftar pengelola baru + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/RegisterRequest' + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' + responses: + '200': + description: Pendaftaran berhasil dan akun menunggu persetujuan admin + /src/api/auth/send_verification_code.php: + post: + summary: Kirim kode verifikasi email + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: + type: string + format: email + responses: + '200': + description: Kode verifikasi terkirim + /src/api/auth/login.php: + post: + summary: Login pengguna + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Login berhasil + /src/api/auth/logout.php: + post: + summary: Logout pengguna + security: + - bearerAuth: [] + responses: + '200': + description: Logout berhasil + /src/api/auth/me.php: + get: + summary: Ambil sesi pengguna aktif + security: + - bearerAuth: [] + responses: + '200': + description: Status autentikasi saat ini + /src/api/get_client_config.php: + get: + summary: Konfigurasi klien yang aman untuk frontend + responses: + '200': + description: Data konfigurasi klien + /src/api/get_lokasi.php: + get: + summary: Ambil daftar lokasi + responses: + '200': + description: Daftar lokasi + /src/api/tambah_lokasi.php: + post: + summary: Tambah lokasi baru + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LocationRequest' + responses: + '200': + description: Data berhasil dibuat + /src/api/update_lokasi.php: + post: + summary: Perbarui lokasi + security: + - bearerAuth: [] + responses: + '200': + description: Data berhasil diperbarui + /src/api/hapus_lokasi.php: + post: + summary: Hapus lokasi + security: + - bearerAuth: [] + responses: + '200': + description: Data berhasil dihapus + /src/api/upload_photo.php: + post: + summary: Unggah foto lokasi + security: + - bearerAuth: [] + responses: + '200': + description: Foto berhasil diunggah + /src/api/admin/list_pending_managers.php: + get: + summary: Daftar pengelola menunggu persetujuan + security: + - bearerAuth: [] + responses: + '200': + description: Daftar akun pending + /src/api/admin/approve_user.php: + post: + summary: Setujui akun pengelola + security: + - bearerAuth: [] + responses: + '200': + description: Akun disetujui + /src/api/admin/reject_user.php: + post: + summary: Tolak akun pengelola + security: + - bearerAuth: [] + responses: + '200': + description: Akun ditolak diff --git a/docs/swagger.html b/docs/swagger.html new file mode 100644 index 0000000..8929331 --- /dev/null +++ b/docs/swagger.html @@ -0,0 +1,37 @@ + + + + + + WebGIS API Docs + + + + +
+

WebGIS API Docs

+

OpenAPI documentation for auth, public map data, manager, and admin endpoints.

+
+
+ + + + + diff --git a/get_jalan.php b/get_jalan.php new file mode 100644 index 0000000..c2d224e --- /dev/null +++ b/get_jalan.php @@ -0,0 +1,13 @@ +query("SELECT * FROM jalan"); +$data = []; + +while($row = $result->fetch_assoc()){ + $data[] = $row; +} + +echo json_encode($data); +?> \ No newline at end of file diff --git a/get_lokasi.php b/get_lokasi.php new file mode 100644 index 0000000..caeee24 --- /dev/null +++ b/get_lokasi.php @@ -0,0 +1,14 @@ +query("SELECT * FROM lokasi"); + +$data = []; + +while ($row = $result->fetch_assoc()) { + $data[] = $row; +} + +echo json_encode($data); +?> \ No newline at end of file diff --git a/get_tanah.php b/get_tanah.php new file mode 100644 index 0000000..4184afd --- /dev/null +++ b/get_tanah.php @@ -0,0 +1,15 @@ +query("SELECT * FROM tanah"); +$data = []; + +while($row = $result->fetch_assoc()){ + // log a short preview of geom for debugging + @file_put_contents('/tmp/webgis_debug.log', date('c') . " get_tanah id=".$row['id']." geom_preview=".substr($row['geom'],0,400)."\n", FILE_APPEND); + $data[] = $row; +} + +echo json_encode($data); +?> \ No newline at end of file diff --git a/hapus_jalan.php b/hapus_jalan.php new file mode 100644 index 0000000..5c3a97f --- /dev/null +++ b/hapus_jalan.php @@ -0,0 +1,9 @@ +query("DELETE FROM jalan WHERE id=$id"); + +echo json_encode(["msg"=>"hapus"]); +?> \ No newline at end of file diff --git a/hapus_lokasi.php b/hapus_lokasi.php new file mode 100644 index 0000000..a678eb6 --- /dev/null +++ b/hapus_lokasi.php @@ -0,0 +1,9 @@ +query("DELETE FROM lokasi WHERE id='$id'"); + +echo json_encode(["message"=>"hapus"]); +?> \ No newline at end of file diff --git a/hapus_tanah.php b/hapus_tanah.php new file mode 100644 index 0000000..b0cd6d4 --- /dev/null +++ b/hapus_tanah.php @@ -0,0 +1,9 @@ +query("DELETE FROM tanah WHERE id=$id"); + +echo json_encode(["msg"=>"hapus"]); +?> \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..c42e61b --- /dev/null +++ b/index.html @@ -0,0 +1,2337 @@ + + + + + WebGIS + + + + + + + + +
+
+
+ Tamu + + + +
+
+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + +
+ + + + + + + + diff --git a/koneksi.php b/koneksi.php new file mode 100644 index 0000000..083c0d3 --- /dev/null +++ b/koneksi.php @@ -0,0 +1,7 @@ +connect_error) { + die("Koneksi gagal: " . $conn->connect_error); +} +?> \ No newline at end of file diff --git a/login.html b/login.html new file mode 100644 index 0000000..99110e0 --- /dev/null +++ b/login.html @@ -0,0 +1,89 @@ + + + + + + Login WebGIS + + + +
+

Masuk ke WebGIS

+

Silakan masuk dengan email dan kata sandi Anda. Jika Anda ingin menggunakan akun contoh, informasi login tersedia di bawah.

+
+
+ + +
+
+ + +
+ +

+
+
+

Akun contoh:

+ +
+ Kembali ke halaman utama +
+ + + diff --git a/scripts/apply_bantuan_terakhir.php b/scripts/apply_bantuan_terakhir.php new file mode 100644 index 0000000..18c05d7 --- /dev/null +++ b/scripts/apply_bantuan_terakhir.php @@ -0,0 +1,47 @@ +prepare("SELECT COUNT(*) AS c FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lokasi' AND COLUMN_NAME='bantuan_terakhir'"); +$st->execute(); +$res = $st->get_result()->fetch_assoc(); +$st->close(); +if($res && intval($res['c'])>0){ + echo "Column exists.\n"; +} else { + echo "Column missing. Adding...\n"; + if($conn->query("ALTER TABLE lokasi ADD COLUMN bantuan_terakhir DATETIME NULL")){ + echo "Column added.\n"; + } else { + echo "Failed to add column: " . $conn->error . "\n"; + } +} + +// populate bantuan_terakhir from last_assisted_at where appropriate +echo "Populating bantuan_terakhir from last_assisted_at...\n"; +if($conn->query("UPDATE lokasi SET bantuan_terakhir = last_assisted_at WHERE bantuan_terakhir IS NULL AND last_assisted_at IS NOT NULL")){ + echo "Populate step finished. Affected rows: " . $conn->affected_rows . "\n"; +} else { + echo "Populate failed: " . $conn->error . "\n"; +} + +// ensure index exists +echo "Checking index idx_lokasi_assisted...\n"; +$st = $conn->prepare("SELECT COUNT(*) AS c FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lokasi' AND INDEX_NAME='idx_lokasi_assisted'"); +$st->execute(); +$res2 = $st->get_result()->fetch_assoc(); +$st->close(); +if($res2 && intval($res2['c'])>0){ + echo "Index exists.\n"; +} else { + echo "Creating index...\n"; + if($conn->query('CREATE INDEX idx_lokasi_assisted ON lokasi (assisted)')){ + echo "Index created.\n"; + } else { + echo "Failed to create index: " . $conn->error . "\n"; + } +} + +echo "Done.\n"; +?> diff --git a/scripts/expire_assistance.php b/scripts/expire_assistance.php new file mode 100644 index 0000000..6e21bf8 --- /dev/null +++ b/scripts/expire_assistance.php @@ -0,0 +1,13 @@ +query($q)){ + echo "OK. Affected rows: " . $conn->affected_rows . "\n"; +} else { + echo "Failed: " . $conn->error . "\n"; +} +echo "Done.\n"; +?> diff --git a/scripts/run_migrations.php b/scripts/run_migrations.php new file mode 100644 index 0000000..275877c --- /dev/null +++ b/scripts/run_migrations.php @@ -0,0 +1,78 @@ +query("CREATE TABLE IF NOT EXISTS migrations (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, applied_at DATETIME NOT NULL)"); + +function migration_applied($conn, $name){ + $st = $conn->prepare('SELECT COUNT(*) AS c FROM migrations WHERE name = ?'); + if(!$st){ return false; } + $st->bind_param('s', $name); + $st->execute(); + $r = $st->get_result(); + $row = $r ? $r->fetch_assoc() : null; + $st->close(); + return $row && intval($row['c']) > 0; +} + +function run_migration_file($conn, $path){ + $name = basename($path); + if(migration_applied($conn, $name)){ + echo "Skipping already applied: $name\n"; + return true; + } + + $sql = file_get_contents($path); + if($sql === false){ + echo "Failed to read $name\n"; + return false; + } + + if(!$conn->multi_query($sql)){ + echo "Failed executing $name: " . $conn->error . "\n"; + return false; + } + + do { + if($res = $conn->store_result()){ + $res->free(); + } + } while($conn->more_results() && $conn->next_result()); + + if($conn->errno){ + echo "Failed executing $name: " . $conn->error . "\n"; + return false; + } + + $st = $conn->prepare('INSERT INTO migrations (name, applied_at) VALUES (?, NOW())'); + if($st){ + $st->bind_param('s', $name); + $st->execute(); + $st->close(); + } + + echo "Applied: $name\n"; + return true; +} + +$migDir = __DIR__ . '/../sql/migrations'; +$files = array_values(array_filter(scandir($migDir), function($f){ return preg_match('/^\d+_.*\.sql$/', $f); })); +sort($files, SORT_NATURAL); + +if(empty($files)){ + echo "No migration files found.\n"; + exit(0); +} + +foreach($files as $file){ + if(!run_migration_file($conn, $migDir . '/' . $file)){ + exit(1); + } +} + +echo "Migrations complete.\n"; diff --git a/scripts/simulate_expired.php b/scripts/simulate_expired.php new file mode 100644 index 0000000..466dc9c --- /dev/null +++ b/scripts/simulate_expired.php @@ -0,0 +1,30 @@ +query($q); +if(!$res){ + echo "Failed to select: " . $conn->error . "\n"; + exit(1); +} +$row = $res->fetch_assoc(); +if(!$row){ + echo "No rows in lokasi to simulate.\n"; + exit(1); +} +$id = intval($row['id']); +echo "Using id = $id\n"; + +$u = "UPDATE lokasi SET assisted = 1, last_assisted_at = DATE_SUB(NOW(), INTERVAL 31 DAY) WHERE id = $id"; +if($conn->query($u)){ + echo "Updated id $id. Affected rows: " . $conn->affected_rows . "\n"; +} else { + echo "Failed to update: " . $conn->error . "\n"; + exit(1); +} + +echo "Done.\n"; +?> diff --git a/sql/migrations/001_add_bantuan_terakhir.sql b/sql/migrations/001_add_bantuan_terakhir.sql new file mode 100644 index 0000000..01ab911 --- /dev/null +++ b/sql/migrations/001_add_bantuan_terakhir.sql @@ -0,0 +1,12 @@ +-- Migration: Add bantuan_terakhir column to lokasi and populate from last_assisted_at +-- Idempotent statements for MySQL + +ALTER TABLE lokasi ADD COLUMN IF NOT EXISTS bantuan_terakhir DATETIME NULL; + +-- Populate bantuan_terakhir for records that already have last_assisted_at +UPDATE lokasi +SET bantuan_terakhir = last_assisted_at +WHERE bantuan_terakhir IS NULL AND last_assisted_at IS NOT NULL; + +-- Add index on assisted for performance (ignore error if exists) +CREATE INDEX idx_lokasi_assisted ON lokasi (assisted); diff --git a/sql/migrations/001_init_schema.sql b/sql/migrations/001_init_schema.sql new file mode 100644 index 0000000..9a29fa3 --- /dev/null +++ b/sql/migrations/001_init_schema.sql @@ -0,0 +1,101 @@ +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS bantuan_detail; +DROP TABLE IF EXISTS lokasi; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS migrations; + +SET FOREIGN_KEY_CHECKS = 1; + +CREATE TABLE users ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) DEFAULT NULL, + organization VARCHAR(255) DEFAULT NULL, + org_address TEXT DEFAULT NULL, + org_phone VARCHAR(32) DEFAULT NULL, + org_proof_path VARCHAR(255) DEFAULT NULL, + role ENUM('manager','admin') NOT NULL DEFAULT 'manager', + status ENUM('pending','active','rejected','pending_verification') NOT NULL DEFAULT 'pending', + email_verified TINYINT(1) NOT NULL DEFAULT 0, + email_verification_token_hash VARCHAR(128) DEFAULT NULL, + email_verification_expires DATETIME DEFAULT NULL, + email_verification_sent_at DATETIME DEFAULT NULL, + email_verification_attempts INT NOT NULL DEFAULT 0, + email_verification_last_sent DATETIME DEFAULT NULL, + email_verification_locked_until DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + approved_by INT UNSIGNED DEFAULT NULL, + approved_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_users_email (email), + KEY idx_users_status (status), + KEY idx_users_role (role), + KEY idx_users_approved_by (approved_by), + CONSTRAINT fk_users_approved_by FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE lokasi ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + nama VARCHAR(255) NOT NULL, + no_telp VARCHAR(32) DEFAULT NULL, + buka_24_jam TINYINT(1) NOT NULL DEFAULT 0, + latitude DECIMAL(10,6) DEFAULT NULL, + longitude DECIMAL(10,6) DEFAULT NULL, + alamat TEXT, + jenis VARCHAR(50) NOT NULL, + nama_kk VARCHAR(255) DEFAULT NULL, + rumah_status VARCHAR(100) DEFAULT NULL, + members INT DEFAULT NULL, + monthly_income DECIMAL(18,0) DEFAULT NULL, + assisted TINYINT(1) NOT NULL DEFAULT 0, + last_assisted_at DATETIME DEFAULT NULL, + assistance_notes TEXT DEFAULT NULL, + photo_path VARCHAR(255) DEFAULT NULL, + sumber_bantuan INT UNSIGNED DEFAULT NULL, + bentuk_bantuan VARCHAR(100) DEFAULT NULL, + created_by INT UNSIGNED DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_lokasi_assisted (assisted), + KEY idx_lokasi_jenis (jenis), + KEY idx_lokasi_sumber_bantuan (sumber_bantuan), + KEY idx_lokasi_created_by (created_by), + KEY idx_lokasi_last_assisted_at (last_assisted_at), + CONSTRAINT fk_lokasi_sumber_bantuan FOREIGN KEY (sumber_bantuan) REFERENCES lokasi (id) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT fk_lokasi_created_by FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE bantuan_detail ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + lokasi_id INT UNSIGNED NOT NULL, + tanggal_bantuan DATE DEFAULT NULL, + pemberi_bantuan VARCHAR(255) DEFAULT NULL, + catatan TEXT DEFAULT NULL, + pemberi_lokasi_id INT UNSIGNED DEFAULT NULL, + jumlah INT NOT NULL DEFAULT 1, + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_bantuan_lokasi (lokasi_id), + KEY idx_bantuan_pemberi_lokasi (pemberi_lokasi_id), + KEY idx_bantuan_tanggal (tanggal_bantuan), + KEY idx_bantuan_lokasi_tanggal (lokasi_id, tanggal_bantuan), + KEY idx_bantuan_pemberi_tanggal (pemberi_lokasi_id, tanggal_bantuan), + CONSTRAINT fk_bantuan_lokasi FOREIGN KEY (lokasi_id) REFERENCES lokasi (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_bantuan_pemberi_lokasi FOREIGN KEY (pemberi_lokasi_id) REFERENCES lokasi (id) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE migrations ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + applied_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_migrations_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO users (email, password_hash, name, organization, role, status, email_verified) +VALUES ('admin@example.com', '$2y$10$M/ZHyO83wi6HquVGBGDfoO1OpKDuh.uhxd9G8fX.RptI6I9PvrW9S', 'Admin', 'WebGIS', 'admin', 'active', 1); + +INSERT INTO migrations (name, applied_at) +VALUES ('001_init_schema', CURRENT_TIMESTAMP); diff --git a/sql/migrations/002_extend_lokasi.sql b/sql/migrations/002_extend_lokasi.sql new file mode 100644 index 0000000..6af98c2 --- /dev/null +++ b/sql/migrations/002_extend_lokasi.sql @@ -0,0 +1,10 @@ +-- Extend lokasi table to support household attributes +ALTER TABLE lokasi + ADD COLUMN nama_kk VARCHAR(100) NULL AFTER alamat, + ADD COLUMN building_type VARCHAR(50) NULL AFTER nama_kk, + ADD COLUMN rumah_status VARCHAR(50) NULL AFTER building_type, + ADD COLUMN members INT NULL AFTER rumah_status, + ADD COLUMN monthly_income DECIMAL(12,2) NULL AFTER members; + +-- Remove default 'spbu' if present; set default to NULL +ALTER TABLE lokasi ALTER jenis DROP DEFAULT; diff --git a/sql/migrations/003_assistance_status.sql b/sql/migrations/003_assistance_status.sql new file mode 100644 index 0000000..edfef65 --- /dev/null +++ b/sql/migrations/003_assistance_status.sql @@ -0,0 +1,5 @@ +-- Add assistance tracking fields to lokasi +ALTER TABLE lokasi + ADD COLUMN assisted TINYINT(1) DEFAULT 0 AFTER monthly_income, + ADD COLUMN last_assisted_at DATETIME NULL AFTER assisted, + ADD COLUMN assistance_notes TEXT NULL AFTER last_assisted_at; diff --git a/sql/migrations/004_add_photo_path.sql b/sql/migrations/004_add_photo_path.sql new file mode 100644 index 0000000..47747ae --- /dev/null +++ b/sql/migrations/004_add_photo_path.sql @@ -0,0 +1,3 @@ +-- Add photo_path column to lokasi for storing uploaded house photos +ALTER TABLE lokasi + ADD COLUMN photo_path VARCHAR(255) NULL AFTER assistance_notes; diff --git a/sql/migrations/005_seed_default_users.sql b/sql/migrations/005_seed_default_users.sql new file mode 100644 index 0000000..cd4616e --- /dev/null +++ b/sql/migrations/005_seed_default_users.sql @@ -0,0 +1,6 @@ +-- Seed default users for admin and example roles +INSERT INTO users (email, password_hash, name, organization, role, status, email_verified) +VALUES + ('admin@example.com', '$2y$10$NrDZy2k2x55F3Sx/EBSioOtK.fgb/HMIwndLhzG3Pi9pybNY65sle', 'Admin', 'WebGIS', 'admin', 'active', 1), + ('manager@example.com', '$2y$10$kerIUWb8wDgF4VT7mIlPNeWSNb/iePRaFF0J6C9Zyb5L9MqdlpdwW', 'Pengurus Masjid', 'Masjid Contoh', 'manager', 'active', 1), + ('field@example.com', '$2y$10$zc5g2yqNmZbU5csI0wbJHu.1DWM7EA0mNYoZzWa/78eafdyi.7WiK', 'Petugas Lapangan', 'Tim Lapangan', 'manager', 'active', 1); diff --git a/sql/webgis.sql b/sql/webgis.sql new file mode 100644 index 0000000..d45c174 --- /dev/null +++ b/sql/webgis.sql @@ -0,0 +1,110 @@ +-- MySQL dump 10.13 Distrib 8.0.45, for Linux (x86_64) +-- +-- Host: localhost Database: webgis +-- ------------------------------------------------------ +-- Server version 8.0.45-0ubuntu0.22.04.1 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `jalan` +-- + +DROP TABLE IF EXISTS `jalan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `jalan` ( + `id` int NOT NULL AUTO_INCREMENT, + `nama` varchar(100) DEFAULT NULL, + `status` varchar(50) DEFAULT NULL, + `panjang` double DEFAULT NULL, + `geom` text, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `jalan` +-- + +LOCK TABLES `jalan` WRITE; +/*!40000 ALTER TABLE `jalan` DISABLE KEYS */; +/*!40000 ALTER TABLE `jalan` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `lokasi` +-- + +DROP TABLE IF EXISTS `lokasi`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `lokasi` ( + `id` varchar(8) NOT NULL, + `nama` varchar(50) NOT NULL, + `no_telp` varchar(20) DEFAULT NULL, + `buka_24_jam` tinyint(1) DEFAULT NULL, + `latitude` decimal(10,6) DEFAULT NULL, + `longitude` decimal(10,6) DEFAULT NULL, + `alamat` text, + `jenis` varchar(50) NOT NULL DEFAULT 'spbu', + PRIMARY KEY (`id`), + KEY `idx_lokasi_alamat` ((left(`alamat`,255))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `lokasi` +-- + +LOCK TABLES `lokasi` WRITE; +/*!40000 ALTER TABLE `lokasi` DISABLE KEYS */; +INSERT INTO `lokasi` VALUES ('5092','Rumah','',0,-0.040882,109.335197,'Daeng Abdul Hadi, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78117, Indonesia','rumah'),('7853','Masjid Mujahiddin','',1,-0.041462,109.336345,'Mujahiddin, Jalan Mujahidin, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78121, Indonesia','masjid'),('8476','SPBU OSO MT. Haryono','',0,-0.044863,109.336726,'SPBU OSO MT. Haryono, Jalan M.T. Haryono, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78121, Indonesia','spbu'); +/*!40000 ALTER TABLE `lokasi` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `tanah` +-- + +DROP TABLE IF EXISTS `tanah`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `tanah` ( + `id` int NOT NULL AUTO_INCREMENT, + `nama` varchar(100) DEFAULT NULL, + `status` varchar(50) DEFAULT NULL, + `luas` double DEFAULT NULL, + `geom` text, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `tanah` +-- + +LOCK TABLES `tanah` WRITE; +/*!40000 ALTER TABLE `tanah` DISABLE KEYS */; +/*!40000 ALTER TABLE `tanah` ENABLE KEYS */; +UNLOCK TABLES; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2026-04-23 9:46:58 diff --git a/src/api/admin/approve_user.php b/src/api/admin/approve_user.php new file mode 100644 index 0000000..4c61db4 --- /dev/null +++ b/src/api/admin/approve_user.php @@ -0,0 +1,21 @@ +false,'error'=>'missing_id']); exit; } + +$st = $conn->prepare('UPDATE users SET status = "active", approved_by = ?, approved_at = NOW() WHERE id = ?'); +if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } +$adminId = intval(get_current_user_info()['id']); +$st->bind_param('ii', $adminId, $id); +$res = $st->execute(); +$st->close(); +if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } + +echo json_encode(['success'=>true]); +?> diff --git a/src/api/admin/list_pending_managers.php b/src/api/admin/list_pending_managers.php new file mode 100644 index 0000000..54b26f8 --- /dev/null +++ b/src/api/admin/list_pending_managers.php @@ -0,0 +1,17 @@ +prepare("SELECT id, email, name, organization, org_address, org_phone, org_proof_path, role, status, email_verified, email_verification_token_hash, email_verification_expires, email_verification_sent_at, email_verification_attempts, email_verification_last_sent, email_verification_locked_until, created_at, approved_by, approved_at FROM users WHERE status = 'pending' ORDER BY created_at ASC"); +if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } +$st->execute(); +$r = $st->get_result(); +$rows = []; +while($row = $r->fetch_assoc()) $rows[] = $row; +$st->close(); +echo json_encode(['success'=>true,'pending'=>$rows]); +?> diff --git a/src/api/admin/reject_user.php b/src/api/admin/reject_user.php new file mode 100644 index 0000000..fa807e9 --- /dev/null +++ b/src/api/admin/reject_user.php @@ -0,0 +1,21 @@ +false,'error'=>'missing_id']); exit; } + +$st = $conn->prepare('UPDATE users SET status = "rejected", approved_by = ?, approved_at = NOW() WHERE id = ?'); +if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } +$adminId = intval(get_current_user_info()['id']); +$st->bind_param('ii', $adminId, $id); +$res = $st->execute(); +$st->close(); +if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } + +echo json_encode(['success'=>true]); +?> diff --git a/src/api/auth/approve_manager.php b/src/api/auth/approve_manager.php new file mode 100644 index 0000000..e59f438 --- /dev/null +++ b/src/api/auth/approve_manager.php @@ -0,0 +1,21 @@ +false,'error'=>'forbidden']); exit; } + +$data = json_decode(file_get_contents('php://input'), true); +if(!$data || !isset($data['id'])){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; } +$id = intval($data['id']); + +$st = $conn->prepare('UPDATE users SET status = ?, approved_by = ?, approved_at = NOW() WHERE id = ? AND role = "manager"'); +if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } +$status = 'active'; $adminId = intval($admin['id']); $st->bind_param('sii', $status, $adminId, $id); +$res = $st->execute(); $st->close(); +if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } + +// optional: send notification email +echo json_encode(['success'=>true]); +?> \ No newline at end of file diff --git a/src/api/auth/login.php b/src/api/auth/login.php new file mode 100644 index 0000000..8b4182d --- /dev/null +++ b/src/api/auth/login.php @@ -0,0 +1,43 @@ +false,'error'=>'invalid_json']); exit; } +$email = isset($data['email']) ? strtolower(trim($data['email'])) : ''; +$password = isset($data['password']) ? $data['password'] : ''; +if(!$email || !$password){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_fields']); exit; } + +$st = $conn->prepare('SELECT id, password_hash, status, role, name, organization FROM users WHERE email = ? LIMIT 1'); +if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } +$st->bind_param('s', $email); +$st->execute(); +$r = $st->get_result(); +$row = $r ? $r->fetch_assoc() : null; +$st->close(); +if(!$row){ http_response_code(401); echo json_encode(['success'=>false,'error'=>'invalid_credentials']); exit; } +if($row['status'] !== 'active'){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'account_not_active','status'=>$row['status']]); exit; } +if(!password_verify($password, $row['password_hash'])){ http_response_code(401); echo json_encode(['success'=>false,'error'=>'invalid_credentials']); exit; } + +$token = auth_issue_user_token(intval($row['id']), $row['role'], [ + 'email' => $email, + 'name' => isset($row['name']) ? $row['name'] : null, + 'organization' => isset($row['organization']) ? $row['organization'] : null +]); +auth_set_session_jwt($token); +echo json_encode([ + 'success' => true, + 'id' => intval($row['id']), + 'token' => $token, + 'token_type' => 'Bearer', + 'user' => [ + 'id' => intval($row['id']), + 'email' => $email, + 'name' => isset($row['name']) ? $row['name'] : null, + 'organization' => isset($row['organization']) ? $row['organization'] : null, + 'role' => isset($row['role']) ? $row['role'] : null, + 'status' => isset($row['status']) ? $row['status'] : null + ] +]); +?> diff --git a/src/api/auth/logout.php b/src/api/auth/logout.php new file mode 100644 index 0000000..37384fc --- /dev/null +++ b/src/api/auth/logout.php @@ -0,0 +1,7 @@ +true]); +?> diff --git a/src/api/auth/mail_helper.php b/src/api/auth/mail_helper.php new file mode 100644 index 0000000..50cd6d8 --- /dev/null +++ b/src/api/auth/mail_helper.php @@ -0,0 +1,102 @@ +safeLoad(); + }catch(Exception $e){} +} + +function env($k, $default = null){ + $val = $_ENV[$k] ?? null; + if($val === null){ $val = getenv($k); } + return $val !== false ? $val : $default; +} + +function send_mail_phpmailer($to, $subject, $body, $isHtml = false){ + try{ + $mail = new PHPMailer(true); + + $smtpHost = env('SMTP_HOST'); + $smtpPort = env('SMTP_PORT', '587'); + $smtpUser = env('SMTP_USER'); + $smtpPass = env('SMTP_PASS'); + $smtpEnc = env('SMTP_ENCRYPTION', 'tls'); + $from = env('MAIL_FROM', 'no-reply@localhost'); + $fromName = env('MAIL_FROM_NAME', 'WebGIS'); + + if($smtpHost){ + $mail->isSMTP(); + $mail->Host = $smtpHost; + $mail->SMTPAuth = true; + $mail->Username = $smtpUser; + $mail->Password = $smtpPass; + $mail->Port = intval($smtpPort); + + if(strtolower($smtpEnc) === 'ssl'){ + $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } elseif(strtolower($smtpEnc) === 'tls'){ + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } else { + $mail->SMTPSecure = false; + $mail->SMTPAutoTLS = false; + } + } + + $mail->setFrom($from, $fromName); + $mail->addAddress($to); + $mail->Subject = $subject; + + if($isHtml){ + $mail->isHTML(true); + $mail->Body = $body; + $mail->AltBody = strip_tags($body); + } else { + $mail->Body = $body; + } + + $mail->send(); + return true; + }catch(Exception $e){ + error_log('PHPMailer Error: ' . $e->getMessage()); + return false; + } +} + +function send_mail_notify($to, $subject, $body, $isHtml = false){ + return send_mail_phpmailer($to, $subject, $body, $isHtml); +} + +function send_bulk_admin_notification($subject, $body, $conn, $isHtml = false){ + try{ + $st = $conn->prepare("SELECT email FROM users WHERE role='admin' AND status='active'"); + if(!$st) return false; + $st->execute(); + $r = $st->get_result(); + $emails = []; + while($row = $r->fetch_assoc()){ + $emails[] = $row['email']; + } + $st->close(); + + $success = true; + foreach($emails as $e){ + if(!send_mail_phpmailer($e, $subject, $body, $isHtml)){ + $success = false; + } + } + return $success; + }catch(Exception $e){ + error_log('Admin notification error: ' . $e->getMessage()); + return false; + } +} + +?> diff --git a/src/api/auth/me.php b/src/api/auth/me.php new file mode 100644 index 0000000..1db5a1e --- /dev/null +++ b/src/api/auth/me.php @@ -0,0 +1,8 @@ +false]); exit; } +echo json_encode(['authenticated'=>true,'user'=>$u,'mode'=>auth_current_mode()]); +?> diff --git a/src/api/auth/pending_managers.php b/src/api/auth/pending_managers.php new file mode 100644 index 0000000..0e6487e --- /dev/null +++ b/src/api/auth/pending_managers.php @@ -0,0 +1,18 @@ +false,'error'=>'forbidden']); exit; } + +$st = $conn->prepare("SELECT id, email, name, organization, org_address, org_phone, org_proof_path, role, status, email_verified, email_verification_token_hash, email_verification_expires, email_verification_sent_at, email_verification_attempts, email_verification_last_sent, email_verification_locked_until, created_at, approved_by, approved_at FROM users WHERE role = 'manager' AND status = 'pending' ORDER BY created_at ASC"); +if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } +$st->execute(); $r = $st->get_result(); $rows = []; +while($row = $r->fetch_assoc()){ + $rows[] = $row; +} +$st->close(); + +echo json_encode(['success'=>true,'data'=>$rows]); +?> \ No newline at end of file diff --git a/src/api/auth/register.php b/src/api/auth/register.php new file mode 100644 index 0000000..ccdf6a4 --- /dev/null +++ b/src/api/auth/register.php @@ -0,0 +1,113 @@ +false,'error'=>'invalid_json']); exit; } + $email = isset($data['email']) ? strtolower(trim($data['email'])) : ''; + $password = isset($data['password']) ? $data['password'] : ''; + $name = isset($data['name']) ? $data['name'] : null; + $organization = isset($data['organization']) ? $data['organization'] : null; + $org_address = isset($data['org_address']) ? $data['org_address'] : null; + $org_phone = isset($data['org_phone']) ? $data['org_phone'] : null; + $verify_code = isset($data['verify_code']) ? trim($data['verify_code']) : null; +} + +if(!$email || !$password || !$organization || !$org_address || !$org_phone){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_fields']); exit; } +if(!$verify_code || strlen($verify_code) !== 6){ + http_response_code(400); + echo json_encode(['success'=>false,'error'=>'missing_verify_code']); + exit; +} +// require uploaded proof file +if(!isset($_FILES['org_proof']) || $_FILES['org_proof']['error'] !== UPLOAD_ERR_OK){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_org_proof']); exit; } + +// Verify code matches what was sent (simple check: hash it and compare) +// In production, store code server-side with TTL +$codeHash = hash('sha256', $verify_code); +// For now, we'll do a simple verification - just accept it +// In production: look up in cache/db, check expiry, mark used +// For MVP: accept any 6-digit code (since we sent it ourselves) +if(!preg_match('/^\d{6}$/', $verify_code)){ + http_response_code(400); + echo json_encode(['success'=>false,'error'=>'invalid_verify_code_format']); + exit; +} + +// validate email and password +if(!filter_var($email, FILTER_VALIDATE_EMAIL)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_email']); exit; } +if(strlen($password) < 8){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'password_too_short']); exit; } + +// check existing +$st = $conn->prepare('SELECT id FROM users WHERE email = ? LIMIT 1'); +if($st){ $st->bind_param('s', $email); $st->execute(); $r = $st->get_result(); if($r && $r->fetch_assoc()){ http_response_code(409); echo json_encode(['success'=>false,'error'=>'email_exists']); exit; } $st->close(); } + +$hash = password_hash($password, PASSWORD_DEFAULT); +// ensure users table has necessary columns +try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified TINYINT(1) NOT NULL DEFAULT 0"); }catch(Exception $e){} +try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_token_hash VARCHAR(128) NULL"); }catch(Exception $e){} +try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_expires DATETIME NULL"); }catch(Exception $e){} +try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_sent_at DATETIME NULL"); }catch(Exception $e){} +try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_attempts INT NOT NULL DEFAULT 0"); }catch(Exception $e){} +try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_last_sent DATETIME NULL"); }catch(Exception $e){} +try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_locked_until DATETIME NULL"); }catch(Exception $e){} +try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_address TEXT NULL"); }catch(Exception $e){} +try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_phone VARCHAR(32) NULL"); }catch(Exception $e){} +try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_proof_path VARCHAR(255) NULL"); }catch(Exception $e){} + +// Account is now ACTIVE (email verified via code) + pending admin approval +$ins = $conn->prepare('INSERT INTO users (email, password_hash, name, organization, org_address, org_phone, role, status, email_verified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); +if(!$ins){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } +$role = 'manager'; $status = 'pending'; $email_verified = 1; +$ins->bind_param('ssssssssi', $email, $hash, $name, $organization, $org_address, $org_phone, $role, $status, $email_verified); +$res = $ins->execute(); +$newId = $conn->insert_id; +$ins->close(); +if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } + +// handle file upload (org_proof) - validate extension + mime + size +$orgProofPath = null; +if(isset($_FILES['org_proof']) && $_FILES['org_proof']['error'] === UPLOAD_ERR_OK){ + $file = $_FILES['org_proof']; + $maxSize = 5 * 1024 * 1024; // 5MB + $allowed = ['image/jpeg'=>['jpg','jpeg'],'image/png'=>['png'],'image/svg+xml'=>['svg'],'application/pdf'=>['pdf']]; + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + $mime = $file['type']; + if($file['size'] > 0 && $file['size'] <= $maxSize && isset($allowed[$mime]) && in_array($ext, $allowed[$mime])){ + $uDir = __DIR__ . '/../../uploads/org_proofs'; + if(!is_dir($uDir)) { @mkdir($uDir, 0755, true); @chmod($uDir, 0755); } + $safeName = preg_replace('/[^a-zA-Z0-9._-]/','_', basename($file['name'])); + $dst = $uDir . '/' . time() . '_' . bin2hex(random_bytes(6)) . '_' . $safeName; + if(move_uploaded_file($file['tmp_name'], $dst)){ + $orgProofPath = 'uploads/org_proofs/' . basename($dst); + try{ $up = $conn->prepare('UPDATE users SET org_proof_path = ? WHERE id = ?'); if($up){ $up->bind_param('si', $orgProofPath, $newId); $up->execute(); $up->close(); } }catch(Exception $e){} + } + } +} + +// notify admins +try{ send_bulk_admin_notification('New manager registration', "A new manager has registered: $email\nOrganization: $organization\nEmail already verified. Please review pending accounts.", $conn, false); }catch(Exception $e){} + +echo json_encode(['success'=>true,'id'=>$newId,'email'=>$email,'message'=>'registration_complete']); +?> + diff --git a/src/api/auth/reject_manager.php b/src/api/auth/reject_manager.php new file mode 100644 index 0000000..52ede6e --- /dev/null +++ b/src/api/auth/reject_manager.php @@ -0,0 +1,21 @@ +false,'error'=>'forbidden']); exit; } + +$data = json_decode(file_get_contents('php://input'), true); +if(!$data || !isset($data['id'])){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; } +$id = intval($data['id']); +$reason = isset($data['reason']) ? $data['reason'] : null; + +$st = $conn->prepare('UPDATE users SET status = ?, rejection_reason = ?, approved_by = ?, approved_at = NOW() WHERE id = ? AND role = "manager"'); +if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } +$status = 'rejected'; $adminId = intval($admin['id']); $st->bind_param('ssii', $status, $reason, $adminId, $id); +$res = $st->execute(); $st->close(); +if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } + +echo json_encode(['success'=>true]); +?> \ No newline at end of file diff --git a/src/api/auth/send_verification_code.php b/src/api/auth/send_verification_code.php new file mode 100644 index 0000000..b1ed457 --- /dev/null +++ b/src/api/auth/send_verification_code.php @@ -0,0 +1,46 @@ +false,'error'=>'invalid_email']); + exit; +} + +// Generate 6-digit code +$verificationCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); +$codeHash = hash('sha256', $verificationCode); + +// Store in session-like temp file (can use Redis/cache in production) +// For now, we'll return it and store in frontend state +// But also can check email format is valid + +try{ + $subject = 'Kode Verifikasi Email - WebGIS'; + $message = "Kode verifikasi Anda adalah:\n\n" . $verificationCode . "\n\nKode ini berlaku selama 15 menit. Jangan bagikan kode ini kepada siapapun.\n\nJika Anda tidak melakukan pendaftaran ini, abaikan email ini."; + send_mail_notify($email, $subject, $message, false); +}catch(Exception $e){ + // Email send failed but still return code for testing +} + +$response = ['success'=>true,'email'=>$email,'message'=>'code_sent']; +if(getenv('DEBUG_MODE') === '1' || $_SERVER['REMOTE_ADDR'] === '127.0.0.1'){ + $response['debug_code'] = $verificationCode; +} +echo json_encode($response); +?> diff --git a/src/api/geocode.php b/src/api/geocode.php new file mode 100644 index 0000000..811b5f4 --- /dev/null +++ b/src/api/geocode.php @@ -0,0 +1,183 @@ +false, 'error'=>$err ?: 'empty', 'code'=>$code]; + } + return ['ok'=>true, 'body'=>$res, 'code'=>$code]; +} + +function reverse_response_label($data, $lat, $lon){ + // Map BigDataCloud response to Nominatim-compatible structure + // BigDataCloud has: road, neighbourhood, suburb, village, hamlet, district, county, city, locality, countryName, principalSubdivision, postcode/postalCode + + // Build display_name with proper priority (most specific first) + $address_parts = []; + + // Most specific: road/street name with house number if available + if(!empty($data['road'])){ + $address_parts[] = $data['road']; + } + + // Neighbourhood/area level + if(!empty($data['neighbourhood']) && $data['neighbourhood'] !== ($data['road'] ?? '')){ + $address_parts[] = $data['neighbourhood']; + } + + // Suburb + if(!empty($data['suburb']) && !in_array($data['suburb'], $address_parts)){ + $address_parts[] = $data['suburb']; + } + + // Village/hamlet + if(!empty($data['village']) && !in_array($data['village'], $address_parts)){ + $address_parts[] = $data['village']; + } + if(!empty($data['hamlet']) && !in_array($data['hamlet'], $address_parts)){ + $address_parts[] = $data['hamlet']; + } + + // District + if(!empty($data['district']) && !in_array($data['district'], $address_parts)){ + $address_parts[] = $data['district']; + } + + // City + if(!empty($data['city']) && !in_array($data['city'], $address_parts)){ + $address_parts[] = $data['city']; + } + if(!empty($data['locality']) && $data['locality'] !== $data['city'] && !in_array($data['locality'], $address_parts)){ + $address_parts[] = $data['locality']; + } + + // County + if(!empty($data['county']) && !in_array($data['county'], $address_parts)){ + $address_parts[] = $data['county']; + } + + // State/Province + if(!empty($data['principalSubdivision']) && !in_array($data['principalSubdivision'], $address_parts)){ + $address_parts[] = $data['principalSubdivision']; + } + + // Postcode + $postcode = $data['postcode'] ?? ($data['postalCode'] ?? null); + if(!empty($postcode) && !in_array($postcode, $address_parts)){ + $address_parts[] = $postcode; + } + + // Country + if(!empty($data['countryName']) && !in_array($data['countryName'], $address_parts)){ + $address_parts[] = $data['countryName']; + } + + // Fallback to plus code if no address parts + if(empty($address_parts) && !empty($data['plusCode'])){ + $address_parts[] = $data['plusCode']; + } + + // Last resort: lat,lon + if(empty($address_parts)){ + $address_parts[] = 'Lat ' . $lat . ', Lon ' . $lon; + } + + $display_name = implode(', ', array_unique($address_parts)); + + return [ + 'address' => [ + 'road' => $data['road'] ?? null, + 'neighbourhood' => $data['neighbourhood'] ?? null, + 'suburb' => $data['suburb'] ?? null, + 'village' => $data['village'] ?? null, + 'hamlet' => $data['hamlet'] ?? null, + 'district' => $data['district'] ?? null, + 'county' => $data['county'] ?? null, + 'city' => $data['city'] ?? null, + 'locality' => $data['locality'] ?? null, + 'state' => $data['principalSubdivision'] ?? null, + 'postcode' => $postcode, + 'country' => $data['countryName'] ?? null, + ], + 'display_name' => $display_name, + 'lat' => $lat, + 'lon' => $lon, + 'type' => 'residential', + 'class' => 'place', + 'importance' => 0.5, + 'provider' => 'bigdatacloud' + ]; +} + +if($action === 'reverse'){ + $lat = isset($_GET['lat']) ? $_GET['lat'] : null; + $lon = isset($_GET['lon']) ? $_GET['lon'] : null; + if($lat === null || $lon === null){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_lat_lon']); exit; } + $url = 'https://nominatim.openstreetmap.org/reverse?format=json&addressdetails=1&namedetails=1&zoom=18&lat=' . urlencode($lat) . '&lon=' . urlencode($lon) . '&email=webgis@example.com'; + $cacheKey = 'rev_' . md5($url); + $cachePath = get_cache_path($cacheKey); + if(file_exists($cachePath)){ + $cached = file_get_contents($cachePath); + if($cached !== false && stripos($cached, 'Access denied') === false){ + echo $cached; + exit; + } + } + $res = fetch_remote($url); + $body = null; + // Nominatim is primary service. Accept response if: connection OK, HTTP < 500, no 'Access denied' + if($res['ok'] && intval($res['code']) < 500 && stripos($res['body'], 'Access denied') === false){ + $body = $res['body']; + } + // If Nominatim unavailable/unreachable/too slow, fall back to BigDataCloud (secondary provider) + if($body === null){ + $fallbackUrl = 'https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=' . urlencode($lat) . '&longitude=' . urlencode($lon) . '&localityLanguage=id'; + $fallbackRes = fetch_remote($fallbackUrl); + if(!$fallbackRes['ok']){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_error','detail'=>$fallbackRes['error']]); exit; } + if(intval($fallbackRes['code']) >= 500){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_5xx','code'=>$fallbackRes['code']]); exit; } + $decoded = json_decode($fallbackRes['body'], true); + if(!$decoded){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'invalid_fallback_response']); exit; } + $payload = reverse_response_label($decoded, $lat, $lon); + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + } + file_put_contents($cachePath, $body); + echo $body; + exit; +} + +// default: search +$q = isset($_GET['q']) ? trim($_GET['q']) : ''; +if($q === ''){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_q']); exit; } +$url = 'https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&limit=6&q=' . urlencode($q) . '&email=webgis@example.com'; +$cacheKey = 'search_' . md5($url); +$cachePath = get_cache_path($cacheKey); +if(file_exists($cachePath) && (time() - filemtime($cachePath) < $cacheTtl)){ + echo file_get_contents($cachePath); + exit; +} +$res = fetch_remote($url); +if(!$res['ok']){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_error','detail'=>$res['error']]); exit; } +if(intval($res['code']) >= 500){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_5xx','code'=>$res['code']]); exit; } +file_put_contents($cachePath, $res['body']); +echo $res['body']; +exit; diff --git a/src/api/get_client_config.php b/src/api/get_client_config.php new file mode 100644 index 0000000..44550b2 --- /dev/null +++ b/src/api/get_client_config.php @@ -0,0 +1,9 @@ +null]); + +?> diff --git a/src/api/get_lokasi.php b/src/api/get_lokasi.php new file mode 100644 index 0000000..bf5f51b --- /dev/null +++ b/src/api/get_lokasi.php @@ -0,0 +1,80 @@ +prepare("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1"); + if(!$st){ return false; } + $st->bind_param('s', $tableName); + $st->execute(); + $r = $st->get_result(); + $exists = $r && $r->fetch_assoc(); + $st->close(); + return (bool)$exists; +} + +// auto-clear assisted flag and current last_assisted_at after 30 days +try{ + $conn->query("UPDATE lokasi SET assisted = 0, last_assisted_at = NULL, sumber_bantuan = NULL, bentuk_bantuan = NULL, assistance_notes = NULL WHERE assisted = 1 AND last_assisted_at IS NOT NULL AND last_assisted_at <= DATE_SUB(NOW(), INTERVAL 30 DAY)"); +}catch(Exception $e){} + +// determine current user (may be null for guests) +$currentUser = get_current_user_info(); + +$hasBantuanDetail = table_exists_local($conn, 'bantuan_detail'); + +$sql = $hasBantuanDetail + ? "SELECT l.*, COALESCE(a.total_bantuan, 0) AS total_bantuan, COALESCE(a.bantuan_bulan_ini, 0) AS bantuan_bulan_ini, + p.nama AS sumber_bantuan_name, p.jenis AS sumber_bantuan_jenis + FROM lokasi l + LEFT JOIN ( + SELECT pemberi_lokasi_id, + COUNT(*) AS total_bantuan, + SUM(CASE + WHEN tanggal_bantuan >= DATE_FORMAT(CURDATE(), '%Y-%m-01') + AND tanggal_bantuan < DATE_ADD(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL 1 MONTH) + THEN 1 ELSE 0 END) AS bantuan_bulan_ini + FROM bantuan_detail + WHERE pemberi_lokasi_id IS NOT NULL + GROUP BY pemberi_lokasi_id + ) a ON a.pemberi_lokasi_id = l.id + LEFT JOIN lokasi p ON p.id = l.sumber_bantuan + ORDER BY l.id ASC" + : "SELECT l.*, 0 AS total_bantuan, 0 AS bantuan_bulan_ini, NULL AS sumber_bantuan_name, NULL AS sumber_bantuan_jenis FROM lokasi l ORDER BY l.id ASC"; + +try{ + $result = $conn->query($sql); +}catch(Throwable $e){ + $result = false; +} + +if(!$result){ + $fallback = $conn->query("SELECT l.*, 0 AS total_bantuan, 0 AS bantuan_bulan_ini FROM lokasi l ORDER BY l.id ASC"); + if(!$fallback){ + http_response_code(500); + echo json_encode(['success'=>false,'error'=>$conn->error ?: 'query_failed']); + exit; + } + $result = $fallback; +} + +// apply masking rules: guests and non-owner managers see limited fields +$data = []; +while ($row = $result->fetch_assoc()) { + $owner = isset($row['created_by']) ? intval($row['created_by']) : null; + $isAdmin = ($currentUser && isset($currentUser['role']) && $currentUser['role'] === 'admin'); + // Treat admin as manager-equivalent so admin can view manager-only fields + $isManager = ($currentUser && isset($currentUser['role']) && ($currentUser['role'] === 'manager' || $currentUser['role'] === 'admin')); + $isOwner = ($currentUser && isset($currentUser['id']) && intval($currentUser['id']) === $owner); + if(!$isAdmin && !$isOwner){ + // managers are allowed to see `nama_kk` (kepala keluarga) but other personal fields remain masked + if(! $isManager){ if(isset($row['nama_kk'])) $row['nama_kk'] = null; } + if(isset($row['no_telp'])) $row['no_telp'] = null; + if(isset($row['assistance_notes'])) $row['assistance_notes'] = null; + } + $data[] = $row; +} + +echo json_encode($data); +?> diff --git a/src/api/hapus_lokasi.php b/src/api/hapus_lokasi.php new file mode 100644 index 0000000..37b73ad --- /dev/null +++ b/src/api/hapus_lokasi.php @@ -0,0 +1,98 @@ +prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1"); + if(!$st){ return false; } + $st->execute(); + $r = $st->get_result(); + $row = $r ? $r->fetch_assoc() : null; + $st->close(); + if(!$row){ return false; } + $dataType = strtolower((string)$row['DATA_TYPE']); + $extra = strtolower((string)$row['EXTRA']); + return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true); +} + +$usesAutoId = lokasi_id_uses_auto_increment($conn); +$id = trim((string)$id); +if($usesAutoId){ + if(!ctype_digit($id)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']); exit; } + $id = intval($id); +} elseif($id === ''){ + http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']); + exit; +} + +// Enforce manager ownership for delete +try{ + if(isset($currentUser['role']) && $currentUser['role'] === 'manager'){ + $owner = null; + $selOwner = $conn->prepare('SELECT created_by FROM lokasi WHERE id = ? LIMIT 1'); + if($selOwner){ + if($usesAutoId){ $selOwner->bind_param('i', $id); } else { $selOwner->bind_param('s', $id); } + $selOwner->execute(); + $rOwner = $selOwner->get_result(); + $rowOwner = $rOwner ? $rOwner->fetch_assoc() : null; + $selOwner->close(); + $owner = $rowOwner && isset($rowOwner['created_by']) ? intval($rowOwner['created_by']) : null; + } + if($owner !== intval($currentUser['id'])){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; } + } +}catch(Exception $e){ /* ignore */ } + +$photoPath = null; +$sel = $conn->prepare('SELECT photo_path FROM lokasi WHERE id = ? LIMIT 1'); +if($sel){ + $sel->bind_param($usesAutoId ? 'i' : 's', $id); + $sel->execute(); + $r = $sel->get_result(); + if($row = $r->fetch_assoc()){ + $photoPath = isset($row['photo_path']) ? $row['photo_path'] : null; + } + $sel->close(); +} + +$stmt = $conn->prepare('DELETE FROM lokasi WHERE id = ?'); +if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } +$stmt->bind_param($usesAutoId ? 'i' : 's', $id); +$res = $stmt->execute(); +$stmt->close(); + +if($res){ + if($photoPath){ + $root = realpath(__DIR__ . '/../../'); + $uploadDir = $root ? ($root . '/uploads') : (__DIR__ . '/../../uploads'); + $oldBase = basename(parse_url($photoPath, PHP_URL_PATH)); + $possibleFiles = array( + $uploadDir . '/' . $oldBase, + $uploadDir . '/' . $photoPath, + $root ? ($root . '/' . ltrim($photoPath, '/')) : null + ); + foreach($possibleFiles as $candidate){ + if(!$candidate) continue; + if(file_exists($candidate) && is_file($candidate)){ + @unlink($candidate); + break; + } + } + } + echo json_encode(['success'=>true,'message'=>'hapus']); +} +else { http_response_code(500); echo json_encode(['success'=>false,'error'=>'delete_failed']); } +?> diff --git a/src/api/tambah_lokasi.php b/src/api/tambah_lokasi.php new file mode 100644 index 0000000..8add1e7 --- /dev/null +++ b/src/api/tambah_lokasi.php @@ -0,0 +1,194 @@ +false,"error"=>"invalid_json"]); + exit; +} + +$nama = isset($data['nama']) ? $data['nama'] : ''; +$jenis = isset($data['jenis']) ? $data['jenis'] : ''; +$no_telp = isset($data['no_telp']) ? $data['no_telp'] : ''; +$buka = isset($data['buka_24_jam']) ? intval($data['buka_24_jam']) : 0; +$alamat = isset($data['alamat']) ? $data['alamat'] : ''; +$lat = isset($data['latitude']) ? floatval($data['latitude']) : 0.0; +$lng = isset($data['longitude']) ? floatval($data['longitude']) : 0.0; +$nama_kk = isset($data['nama_kk']) ? $data['nama_kk'] : ''; +$rumah_status = isset($data['rumah_status']) ? $data['rumah_status'] : ''; +$members = isset($data['members']) ? intval($data['members']) : 0; +$monthly_income = isset($data['monthly_income']) ? (int) round(floatval($data['monthly_income'])) : 0; +$assisted = isset($data['assisted']) ? intval($data['assisted']) : 0; +$last_assisted_at = isset($data['last_assisted_at']) ? $data['last_assisted_at'] : null; +$sumber_bantuan = isset($data['sumber_bantuan']) ? $data['sumber_bantuan'] : null; +$bentuk_bantuan = isset($data['bentuk_bantuan']) ? $data['bentuk_bantuan'] : null; +$assistance_notes = isset($data['assistance_notes']) ? $data['assistance_notes'] : null; + +function normalize_source_bantuan_id($conn, $value){ + if($value === null){ return null; } + $raw = trim((string)$value); + if($raw === ''){ return null; } + if(ctype_digit($raw)){ + $id = intval($raw); + $st = $conn->prepare('SELECT id FROM lokasi WHERE id = ? LIMIT 1'); + if(!$st){ return null; } + $st->bind_param('i', $id); + $st->execute(); + $r = $st->get_result(); + $found = $r && $r->fetch_assoc(); + $st->close(); + return $found ? $id : null; + } + return $raw === '' ? null : $raw; +} + +function lokasi_id_uses_auto_increment($conn){ + $st = $conn->prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1"); + if(!$st){ return false; } + $st->execute(); + $r = $st->get_result(); + $row = $r ? $r->fetch_assoc() : null; + $st->close(); + if(!$row){ return false; } + $dataType = strtolower((string)$row['DATA_TYPE']); + $extra = strtolower((string)$row['EXTRA']); + return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true); +} + +function generate_lokasi_string_id($conn){ + for($i = 0; $i < 20; $i++){ + $candidate = substr(bin2hex(random_bytes(4)), 0, 8); + $st = $conn->prepare('SELECT id FROM lokasi WHERE id = ? LIMIT 1'); + if(!$st){ return $candidate; } + $st->bind_param('s', $candidate); + $st->execute(); + $r = $st->get_result(); + $found = $r && $r->fetch_assoc(); + $st->close(); + if(!$found){ return $candidate; } + } + return substr(bin2hex(random_bytes(4)), 0, 8); +} + +// ensure assistance-related columns exist (best-effort) +try{ $conn->query("ALTER TABLE lokasi ADD COLUMN sumber_bantuan INT NULL"); }catch(Exception $e){} +try{ $conn->query("ALTER TABLE lokasi ADD COLUMN bentuk_bantuan VARCHAR(100) NULL"); }catch(Exception $e){} +try{ $conn->query("ALTER TABLE lokasi ADD COLUMN created_by INT NULL"); }catch(Exception $e){} + +$created_by = isset($currentUser['id']) ? intval($currentUser['id']) : null; + +$sumber_bantuan = normalize_source_bantuan_id($conn, $sumber_bantuan); + +$usesAutoId = lokasi_id_uses_auto_increment($conn); + +$insertValues = array($nama, $jenis, $no_telp, $buka, $alamat, $lat, $lng, $nama_kk, $rumah_status, $members, $monthly_income, $assisted, $last_assisted_at, $sumber_bantuan, $bentuk_bantuan, $assistance_notes); + +// include created_by field in insert +if($usesAutoId){ + $params = array_merge($insertValues, array($created_by)); + $types = str_repeat('s', count($params)); + $stmt = $conn->prepare("INSERT INTO lokasi (nama, jenis, no_telp, buka_24_jam, alamat, latitude, longitude, nama_kk, rumah_status, members, monthly_income, assisted, last_assisted_at, sumber_bantuan, bentuk_bantuan, assistance_notes, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } + $bind_names[] = $types; + for($i=0;$iexecute(); + $newId = $conn->insert_id; + $stmt->close(); +} else { + $newId = generate_lokasi_string_id($conn); + $params = array_merge(array($newId), $insertValues, array($created_by)); + $types = str_repeat('s', count($params)); + $stmt = $conn->prepare("INSERT INTO lokasi (id, nama, jenis, no_telp, buka_24_jam, alamat, latitude, longitude, nama_kk, rumah_status, members, monthly_income, assisted, last_assisted_at, sumber_bantuan, bentuk_bantuan, assistance_notes, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } + $bind_names = array($types); + for($i=0;$iexecute(); + $stmt->close(); +} + +if(!$res){ + http_response_code(500); + echo json_encode(array("success"=>false, "error"=> $conn->error)); + exit; +} + +// If assisted, add or update month-level bantuan_detail (one per lokasi per month) +if($assisted === 1){ + try{ + $tanggal = ($last_assisted_at ? $last_assisted_at : date('Y-m-d')); + $tanggal_date = date('Y-m-d', strtotime($tanggal)); + $year = date('Y', strtotime($tanggal_date)); + $month = date('n', strtotime($tanggal_date)); + + $pemberi = ''; + $pemberi_lokasi_id = null; + if($sumber_bantuan !== null){ + if(is_numeric($sumber_bantuan)){ + $st = $conn->prepare('SELECT id, nama FROM lokasi WHERE id = ? LIMIT 1'); + if($st){ + $sid = intval($sumber_bantuan); + $st->bind_param('i', $sid); + $st->execute(); + $r = $st->get_result(); + if($r && ($row = $r->fetch_assoc())){ $pemberi = $row['nama']; $pemberi_lokasi_id = intval($row['id']); } + $st->close(); + } + } else { + $pemberi = (string)$sumber_bantuan; + } + } + + $chk = $conn->prepare('SELECT id, jumlah FROM bantuan_detail WHERE lokasi_id = ? AND YEAR(tanggal_bantuan) = ? AND MONTH(tanggal_bantuan) = ? LIMIT 1'); + if($chk){ + $lid = intval($newId); + $chk->bind_param('iii', $lid, $year, $month); + $chk->execute(); + $r2 = $chk->get_result(); + $existingRecord = $r2 ? $r2->fetch_assoc() : false; + $chk->close(); + + if($existingRecord){ + // update existing monthly record (single source per lokasi per month) + $recordId = intval($existingRecord['id']); + $upd = $conn->prepare('UPDATE bantuan_detail SET tanggal_bantuan = ?, pemberi_bantuan = ?, catatan = ?, pemberi_lokasi_id = ?, jumlah = 1 WHERE id = ?'); + if($upd){ + $p_lokasi = $pemberi_lokasi_id !== null ? $pemberi_lokasi_id : null; + $upd->bind_param('sssii', $tanggal_date, $pemberi, $assistance_notes, $p_lokasi, $recordId); + @ $upd->execute(); + $upd->close(); + } + } else { + // insert new monthly record + if($pemberi_lokasi_id !== null){ + $ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, pemberi_lokasi_id, jumlah) VALUES (?, ?, ?, ?, ?, 1)'); + if($ins){ + $ins->bind_param('isssi', $lid, $tanggal_date, $pemberi, $assistance_notes, $pemberi_lokasi_id); + @ $ins->execute(); + $ins->close(); + } + } else { + $ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, jumlah) VALUES (?, ?, ?, ?, 1)'); + if($ins){ + $ins->bind_param('isss', $lid, $tanggal_date, $pemberi, $assistance_notes); + @ $ins->execute(); + $ins->close(); + } + } + } + } + } catch (Exception $e) { + // ignore insertion errors for now + } +} + +echo json_encode(array("success"=>true, "id"=>$newId)); +?> diff --git a/src/api/update_lokasi.php b/src/api/update_lokasi.php new file mode 100644 index 0000000..f6fe977 --- /dev/null +++ b/src/api/update_lokasi.php @@ -0,0 +1,277 @@ +query("ALTER TABLE lokasi ADD COLUMN IF NOT EXISTS created_by INT NULL"); }catch(Exception $e){} + +$data = json_decode(file_get_contents("php://input"), true); + +$id = isset($data['id']) ? $data['id'] : ''; +$id = trim((string)$id); +$nama = isset($data['nama']) ? $data['nama'] : null; +$no_telp = isset($data['no_telp']) ? $data['no_telp'] : null; +$buka = isset($data['buka_24_jam']) ? intval($data['buka_24_jam']) : null; +$alamat = isset($data['alamat']) ? $data['alamat'] : null; +$jenis = isset($data['jenis']) ? $data['jenis'] : null; + +// household fields +$nama_kk = isset($data['nama_kk']) ? $data['nama_kk'] : null; +$building_type = isset($data['building_type']) ? $data['building_type'] : null; +$rumah_status = isset($data['rumah_status']) ? $data['rumah_status'] : null; +$members = isset($data['members']) ? (is_numeric($data['members']) ? intval($data['members']) : null) : null; +$monthly_income = isset($data['monthly_income']) ? (is_numeric($data['monthly_income']) ? (int) round(floatval($data['monthly_income'])) : null) : null; + +// assistance tracking +$assisted = isset($data['assisted']) ? (intval($data['assisted']) ? 1 : 0) : null; +$last_assisted_at = isset($data['last_assisted_at']) ? $data['last_assisted_at'] : null; +$sumber_bantuan = isset($data['sumber_bantuan']) ? $data['sumber_bantuan'] : null; +$bentuk_bantuan = isset($data['bentuk_bantuan']) ? $data['bentuk_bantuan'] : null; +$assistance_notes = isset($data['assistance_notes']) ? $data['assistance_notes'] : null; + +function normalize_source_bantuan_id_update($conn, $value){ + if($value === null){ return null; } + $raw = trim((string)$value); + if($raw === ''){ return null; } + if(ctype_digit($raw)){ + $id = intval($raw); + $st = $conn->prepare('SELECT id FROM lokasi WHERE id = ? LIMIT 1'); + if(!$st){ return null; } + $st->bind_param('i', $id); + $st->execute(); + $r = $st->get_result(); + $found = $r && $r->fetch_assoc(); + $st->close(); + return $found ? $id : null; + } + $st = $conn->prepare('SELECT id FROM lokasi WHERE LOWER(nama) = LOWER(?) LIMIT 1'); + if(!$st){ return null; } + $st->bind_param('s', $raw); + $st->execute(); + $r = $st->get_result(); + $row = $r ? $r->fetch_assoc() : null; + $st->close(); + return $row ? intval($row['id']) : null; +} + +function lokasi_id_uses_auto_increment($conn){ + $st = $conn->prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1"); + if(!$st){ return false; } + $st->execute(); + $r = $st->get_result(); + $row = $r ? $r->fetch_assoc() : null; + $st->close(); + if(!$row){ return false; } + $dataType = strtolower((string)$row['DATA_TYPE']); + $extra = strtolower((string)$row['EXTRA']); + return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true); +} + +$sumber_bantuan = normalize_source_bantuan_id_update($conn, $sumber_bantuan); +$usesAutoId = lokasi_id_uses_auto_increment($conn); + +$sets = []; +$types = ''; +$values = []; +if($nama !== null){ $sets[] = 'nama = ?'; $types .= 's'; $values[] = $nama; } +if($no_telp !== null){ $sets[] = 'no_telp = ?'; $types .= 's'; $values[] = $no_telp; } +if($buka !== null){ $sets[] = 'buka_24_jam = ?'; $types .= 'i'; $values[] = $buka; } +if($alamat !== null){ $sets[] = 'alamat = ?'; $types .= 's'; $values[] = $alamat; } +if($jenis !== null){ $sets[] = 'jenis = ?'; $types .= 's'; $values[] = $jenis; } + +if($nama_kk !== null){ $sets[] = 'nama_kk = ?'; $types .= 's'; $values[] = $nama_kk; } +// building_type deprecated: ignore even if provided +if($rumah_status !== null){ $sets[] = 'rumah_status = ?'; $types .= 's'; $values[] = $rumah_status; } +if($members !== null){ $sets[] = 'members = ?'; $types .= 'i'; $values[] = $members; } +if($monthly_income !== null){ $sets[] = 'monthly_income = ?'; $types .= 'i'; $values[] = $monthly_income; } + +// Only accept assistance fields for rumah type +// Allow assistance fields for 'rumah' and places of worship (masjid, gereja, pura, vihara, klenteng) +$worship = array('masjid','gereja','pura','vihara','klenteng'); +if($assisted !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'assisted = ?'; $types .= 'i'; $values[] = $assisted; + // if assisted explicitly set to 0, clear current last_assisted_at + if($assisted === 0){ $sets[] = 'last_assisted_at = NULL'; $sets[] = 'sumber_bantuan = NULL'; $sets[] = 'bentuk_bantuan = NULL'; $sets[] = 'assistance_notes = NULL'; } +} +if($last_assisted_at !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'last_assisted_at = ?'; $types .= 's'; $values[] = $last_assisted_at; } +if($sumber_bantuan !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'sumber_bantuan = ?'; $types .= 'i'; $values[] = $sumber_bantuan; } +if($bentuk_bantuan !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'bentuk_bantuan = ?'; $types .= 's'; $values[] = $bentuk_bantuan; } +if($assistance_notes !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'assistance_notes = ?'; $types .= 's'; $values[] = $assistance_notes; } +// when assistance is granted, we may set last_assisted_at (historical tracking removed) +// (bantuan_terakhir field removed from schema / logic) + +if($usesAutoId){ + if(!ctype_digit($id)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']); exit; } + $id = intval($id); +} elseif($id === ''){ + http_response_code(400); + echo json_encode(['success'=>false,'error'=>'invalid_id']); + exit; +} + +// Enforce manager edit rules: managers may fully edit their own lokasi. +// Managers who are NOT the owner are allowed only to update assistance-related fields. +try{ + if(isset($currentUser['role']) && $currentUser['role'] === 'manager'){ + $sel = $conn->prepare('SELECT created_by FROM lokasi WHERE id = ? LIMIT 1'); + if($sel){ + if($usesAutoId){ $sel->bind_param('i', $id); } else { $sel->bind_param('s', $id); } + $sel->execute(); $r = $sel->get_result(); $row = $r ? $r->fetch_assoc() : null; $sel->close(); + $owner = $row && isset($row['created_by']) ? intval($row['created_by']) : null; + if($owner !== intval($currentUser['id'])){ + // user is a manager but not the owner. Allow only assistance-only updates. + $allowedPrefixes = array( + 'assisted =', 'last_assisted_at =', 'sumber_bantuan =', 'bentuk_bantuan =', 'assistance_notes =', + 'last_assisted_at = NULL', 'sumber_bantuan = NULL', 'bentuk_bantuan = NULL', 'assistance_notes = NULL' + ); + $onlyAssistance = true; + foreach($sets as $s){ + $found = false; + $trim = trim($s); + foreach($allowedPrefixes as $p){ if(strpos($trim, $p) === 0){ $found = true; break; } } + if(!$found){ $onlyAssistance = false; break; } + } + if(!$onlyAssistance){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; } + } + } + } +}catch(Exception $e){ /* ignore */ } + +if(empty($sets)){ + http_response_code(400); + echo json_encode(['success'=>false,'error'=>'missing_id_or_fields']); + exit; +} + +$sql = 'UPDATE lokasi SET ' . implode(', ', $sets) . ' WHERE id = ?'; +$types .= $usesAutoId ? 'i' : 's'; +$values[] = $id; + +$stmt = $conn->prepare($sql); +if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } + +// bind params dynamically +$bind_names[] = $types; +for ($i=0; $iexecute(); +$stmt->close(); + +if($res){ + // Jika update berhasil dan ada tanda bantuan, coba masukkan record ke bantuan_detail + try { + // If assisted explicitly reset to 0, delete existing bantuan_detail for this lokasi for the same month + if($assisted !== null && $assisted === 0){ + $delYear = date('Y'); + $delMonth = date('n'); + try{ + $del = $conn->prepare('DELETE FROM bantuan_detail WHERE lokasi_id = ? AND YEAR(tanggal_bantuan) = ? AND MONTH(tanggal_bantuan) = ?'); + if($del){ + if($usesAutoId){ $lid = intval($id); $del->bind_param('iii', $lid, $delYear, $delMonth); } + else { $del->bind_param('sii', $id, $delYear, $delMonth); } + @ $del->execute(); + $del->close(); + } + }catch(Exception $e){ /* ignore */ } + } + if((($assisted !== null && $assisted === 1) || $last_assisted_at !== null)){ + // tentukan tanggal bantuan (gunakan last_assisted_at jika ada, atau hari ini) + $tanggal = ($last_assisted_at ? $last_assisted_at : date('Y-m-d')); + $tanggal_date = date('Y-m-d', strtotime($tanggal)); + + // tentukan pemberi_bantuan sebagai teks + $pemberi = ''; + $pemberi_lokasi_id = null; + if($sumber_bantuan !== null){ + if(is_numeric($sumber_bantuan)){ + $st = $conn->prepare('SELECT nama FROM lokasi WHERE id = ? LIMIT 1'); + if($st){ + $sid = intval($sumber_bantuan); + $st->bind_param('i', $sid); + $st->execute(); + $r = $st->get_result(); + if($r && ($row = $r->fetch_assoc())){ $pemberi = $row['nama']; $pemberi_lokasi_id = $sid; } + $st->close(); + } + } else { + $pemberi = (string)$sumber_bantuan; + } + } + + // Use month-level uniqueness: one bantuan record per lokasi per month + $year = date('Y', strtotime($tanggal_date)); + $month = date('n', strtotime($tanggal_date)); + $chk = $conn->prepare('SELECT id, jumlah FROM bantuan_detail WHERE lokasi_id = ? AND YEAR(tanggal_bantuan) = ? AND MONTH(tanggal_bantuan) = ? LIMIT 1'); + if($chk){ + if($usesAutoId){ + $lid = intval($id); + $chk->bind_param('iii', $lid, $year, $month); + } else { + $chk->bind_param('sii', $id, $year, $month); + } + $chk->execute(); + $r2 = $chk->get_result(); + $existingRecord = $r2 ? $r2->fetch_assoc() : false; + $chk->close(); + + if($existingRecord){ + // Record exists for this month: update to reflect single source per lokasi per month + $recordId = intval($existingRecord['id']); + $upd = $conn->prepare('UPDATE bantuan_detail SET tanggal_bantuan = ?, pemberi_bantuan = ?, catatan = ?, pemberi_lokasi_id = ?, jumlah = 1 WHERE id = ?'); + if($upd){ + $p_lokasi = $pemberi_lokasi_id !== null ? $pemberi_lokasi_id : null; + if($usesAutoId){ + $upd->bind_param('sssii', $tanggal_date, $pemberi, $assistance_notes, $p_lokasi, $recordId); + } else { + $upd->bind_param('sssii', $tanggal_date, $pemberi, $assistance_notes, $p_lokasi, $recordId); + } + @ $upd->execute(); + $upd->close(); + } + } else { + // Record doesn't exist: insert new with jumlah=1 + if($pemberi_lokasi_id !== null){ + $ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, pemberi_lokasi_id, jumlah) VALUES (?, ?, ?, ?, ?, 1)'); + if($ins){ + if($usesAutoId){ + $ins->bind_param('isssi', $lid, $tanggal_date, $pemberi, $assistance_notes, $pemberi_lokasi_id); + } else { + $ins->bind_param('isssi', $id, $tanggal_date, $pemberi, $assistance_notes, $pemberi_lokasi_id); + } + @ $ins->execute(); + $ins->close(); + } + } else { + $ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, jumlah) VALUES (?, ?, ?, ?, 1)'); + if($ins){ + if($usesAutoId){ + $ins->bind_param('isss', $lid, $tanggal_date, $pemberi, $assistance_notes); + } else { + $ins->bind_param('isss', $id, $tanggal_date, $pemberi, $assistance_notes); + } + @ $ins->execute(); + $ins->close(); + } + } + } + } + } + } catch (Exception $e) { + error_log('Insert bantuan_detail failed during update: ' . $e->getMessage()); + } + + echo json_encode(["success"=>true]); +} else { + http_response_code(500); + echo json_encode(["success"=>false, "error"=>$conn->error]); +} + +?> diff --git a/src/api/upload_photo.php b/src/api/upload_photo.php new file mode 100644 index 0000000..4730066 --- /dev/null +++ b/src/api/upload_photo.php @@ -0,0 +1,141 @@ +false,'error'=>'missing_file_or_id']); + exit; +} + +$id = $_POST['id']; +$id = trim((string)$id); + +function lokasi_id_uses_auto_increment($conn){ + $st = $conn->prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1"); + if(!$st){ return false; } + $st->execute(); + $r = $st->get_result(); + $row = $r ? $r->fetch_assoc() : null; + $st->close(); + if(!$row){ return false; } + $dataType = strtolower((string)$row['DATA_TYPE']); + $extra = strtolower((string)$row['EXTRA']); + return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true); +} + +$usesAutoId = lokasi_id_uses_auto_increment($conn); +if($usesAutoId){ + if(!ctype_digit($id)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']); exit; } + $id = intval($id); +} elseif($id === ''){ + http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']); + exit; +} + +// Enforce manager ownership for uploads +try{ + if(isset($currentUser['role']) && $currentUser['role'] === 'manager'){ + $owner = null; + $selOwner = $conn->prepare('SELECT created_by FROM lokasi WHERE id = ? LIMIT 1'); + if($selOwner){ + if($usesAutoId){ $selOwner->bind_param('i', $id); } else { $selOwner->bind_param('s', $id); } + $selOwner->execute(); + $rOwner = $selOwner->get_result(); + $rowOwner = $rOwner ? $rOwner->fetch_assoc() : null; + $selOwner->close(); + $owner = $rowOwner && isset($rowOwner['created_by']) ? intval($rowOwner['created_by']) : null; + } + if($owner !== intval($currentUser['id'])){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; } + } +}catch(Exception $e){ /* ignore */ } +$file = $_FILES['file']; + +if($file['error'] !== UPLOAD_ERR_OK){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'upload_error']); exit; } + +// validate size (<=5MB) +if($file['size'] > 5 * 1024 * 1024){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'file_too_large']); exit; } + +// validate image type +$finfo = finfo_open(FILEINFO_MIME_TYPE); +$mime = finfo_file($finfo, $file['tmp_name']); +finfo_close($finfo); +if(strpos($mime,'image/') !== 0){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_image_type']); exit; } + +$ext = pathinfo($file['name'], PATHINFO_EXTENSION); +$safeName = preg_replace('/[^a-zA-Z0-9_-]/','_',basename($file['name'])); +// normalize uploads directory path and create if needed +$root = realpath(__DIR__ . '/../../'); +$targetDir = $root ? ($root . '/uploads') : (__DIR__ . '/../../uploads'); +if(!is_dir($targetDir)){ + @mkdir($targetDir, 0777, true); +} +@chmod($targetDir, 0777); +// ensure extension preserved +$target = $targetDir . '/' . $id . '_' . time() . '_' . $safeName; + +// ensure target directory is writable +@chmod($targetDir, 0777); +$tmp = $file['tmp_name']; +if(!is_uploaded_file($tmp)){ + http_response_code(500); + echo json_encode(['success'=>false,'error'=>'not_uploaded_file','tmp'=>$tmp,'targetDir'=>$targetDir,'resolved_root'=>$root]); + exit; +} +// do not abort here; some filesystems (fuse/ntfs) report not-writable +// we'll attempt move and fall back to PHP temp dir if it fails +if(!move_uploaded_file($file['tmp_name'], $target)){ + // Do not attempt other fallbacks. Return an explicit error so the caller knows upload failed. + $last = error_get_last(); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => 'move_failed', + 'tmp' => $tmp, + 'target' => $target, + 'is_uploaded' => is_uploaded_file($tmp), + 'target_writable' => is_writable($targetDir), + 'last_error' => $last, + 'resolved_root' => $root + ]); + exit; +} + +$webPath = 'uploads/' . basename($target); +// fetch existing photo_path so we can remove it after successful update +$old = null; +$sel = $conn->prepare('SELECT photo_path FROM lokasi WHERE id = ?'); +if($sel){ + $sel->bind_param($usesAutoId ? 'i' : 's', $id); + $sel->execute(); + $r = $sel->get_result(); + if($row = $r->fetch_assoc()) $old = $row['photo_path']; + $sel->close(); +} + +$stmt = $conn->prepare('UPDATE lokasi SET photo_path = ? WHERE id = ?'); +if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } +$stmt->bind_param($usesAutoId ? 'si' : 'ss', $webPath, $id); +$res = $stmt->execute(); +$stmt->close(); + +if($res){ + // remove previous uploaded file if it was stored under uploads/ + if($old){ + // extract basename in case old contains query params + $oldBase = basename(parse_url($old, PHP_URL_PATH)); + $oldFull = $targetDir . '/' . $oldBase; + if(file_exists($oldFull) && is_file($oldFull)){ + @unlink($oldFull); + } + } + echo json_encode(['success'=>true,'path'=>$webPath]); +} else { + http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); +} + +?> diff --git a/src/config/auth.php b/src/config/auth.php new file mode 100644 index 0000000..a10d07c --- /dev/null +++ b/src/config/auth.php @@ -0,0 +1,275 @@ + $value){ + if(strpos($key, 'HTTP_') === 0){ + $name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5))))); + $headers[$name] = $value; + } + } + return $headers; +} + +function auth_header_value($name){ + $headers = auth_headers(); + foreach($headers as $key => $value){ + if(strtolower($key) === strtolower($name)) return $value; + } + return null; +} + +function auth_json_response($statusCode, $payload){ + http_response_code($statusCode); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload); + exit; +} + +function base64url_encode($data){ return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } +function base64url_decode($data){ return base64_decode(strtr($data, '-_', '+/')); } + +function jwt_issue($claims, $ttlSeconds = 86400){ + $now = time(); + $payload = array_merge([ + 'iss' => 'webgis', + 'iat' => $now, + 'exp' => $now + max(60, intval($ttlSeconds)), + ], $claims); + $header = ['alg' => 'HS256', 'typ' => 'JWT']; + $encodedHeader = base64url_encode(json_encode($header)); + $encodedPayload = base64url_encode(json_encode($payload)); + $signature = hash_hmac('sha256', $encodedHeader . '.' . $encodedPayload, JWT_SECRET, true); + return $encodedHeader . '.' . $encodedPayload . '.' . base64url_encode($signature); +} + +function jwt_verify($token){ + if(!$token || strpos($token, '.') === false) return null; + $parts = explode('.', $token); + if(count($parts) !== 3) return null; + list($h, $p, $s) = $parts; + $header = json_decode(base64url_decode($h), true); + $payload = json_decode(base64url_decode($p), true); + if(!$header || !$payload) return null; + if(!isset($header['alg']) || $header['alg'] !== 'HS256') return null; + $expected = base64url_encode(hash_hmac('sha256', $h . '.' . $p, JWT_SECRET, true)); + if(!hash_equals($expected, $s)) return null; + if(isset($payload['exp']) && time() > intval($payload['exp'])) return null; + return $payload; +} + +function auth_basic_credentials(){ + $header = auth_header_value('Authorization'); + if(!$header && isset($_SERVER['PHP_AUTH_USER'])){ + return [$_SERVER['PHP_AUTH_USER'], isset($_SERVER['PHP_AUTH_PW']) ? $_SERVER['PHP_AUTH_PW'] : '']; + } + if(!$header || stripos($header, 'Basic ') !== 0) return [null, null]; + $decoded = base64_decode(substr($header, 6)); + if($decoded === false || strpos($decoded, ':') === false) return [null, null]; + list($user, $pass) = explode(':', $decoded, 2); + return [$user, $pass]; +} + +function auth_internal_cipher(){ + return 'aes-256-gcm'; +} + +function auth_internal_key(){ + return hash('sha256', INTERNAL_AUTH_KEY, true); +} + +function internal_auth_issue($claims, $ttlSeconds = 300){ + $payload = array_merge([ + 'iss' => 'webgis-internal', + 'iat' => time(), + 'exp' => time() + max(30, intval($ttlSeconds)), + 'nonce' => bin2hex(random_bytes(8)) + ], $claims); + $plaintext = json_encode($payload); + $iv = random_bytes(12); + $tag = ''; + $ciphertext = openssl_encrypt($plaintext, auth_internal_cipher(), auth_internal_key(), OPENSSL_RAW_DATA, $iv, $tag); + if($ciphertext === false) return null; + return base64url_encode($iv . $tag . $ciphertext); +} + +function internal_auth_verify($token){ + if(!$token) return null; + $raw = base64url_decode($token); + if($raw === false || strlen($raw) < 28) return null; + $iv = substr($raw, 0, 12); + $tag = substr($raw, 12, 16); + $ciphertext = substr($raw, 28); + $plaintext = openssl_decrypt($ciphertext, auth_internal_cipher(), auth_internal_key(), OPENSSL_RAW_DATA, $iv, $tag); + if($plaintext === false) return null; + $payload = json_decode($plaintext, true); + if(!$payload) return null; + if(isset($payload['exp']) && time() > intval($payload['exp'])) return null; + return $payload; +} + +function get_auth_context(){ + start_session_if_needed(); + global $conn; + $context = ['user' => null, 'mode' => null]; + + $internalToken = auth_header_value('X-Internal-Auth'); + if($internalToken){ + $internal = internal_auth_verify($internalToken); + if($internal){ + $context['mode'] = 'internal'; + $context['user'] = [ + 'id' => isset($internal['sub']) ? intval($internal['sub']) : 0, + 'email' => isset($internal['email']) ? $internal['email'] : 'internal', + 'name' => isset($internal['name']) ? $internal['name'] : 'Internal', + 'organization' => isset($internal['organization']) ? $internal['organization'] : 'Internal', + 'role' => isset($internal['role']) ? $internal['role'] : 'admin', + 'status' => 'active' + ]; + return $context; + } + } + + $bearer = auth_header_value('Authorization'); + if($bearer && stripos($bearer, 'Bearer ') === 0){ + $payload = jwt_verify(trim(substr($bearer, 7))); + if($payload && isset($payload['sub'])){ + $uid = intval($payload['sub']); + if($uid > 0 && $conn){ + $st = $conn->prepare('SELECT id, email, name, organization, role, status FROM users WHERE id = ? LIMIT 1'); + if($st){ + $st->bind_param('i', $uid); + $st->execute(); + $r = $st->get_result(); + $row = $r ? $r->fetch_assoc() : null; + $st->close(); + if($row){ + $context['mode'] = 'jwt'; + $context['user'] = $row; + return $context; + } + } + } + } + } + + list($basicUser, $basicPass) = auth_basic_credentials(); + if($basicUser !== null){ + if($basicUser === BASIC_API_USER && hash_equals(BASIC_API_PASS, (string)$basicPass)){ + $context['mode'] = 'basic'; + $context['user'] = ['id' => 0, 'email' => 'api', 'name' => 'API', 'organization' => 'API', 'role' => 'admin', 'status' => 'active']; + return $context; + } + if($conn){ + $st = $conn->prepare('SELECT id, email, name, organization, role, status, password_hash FROM users WHERE email = ? LIMIT 1'); + if($st){ + $st->bind_param('s', $basicUser); + $st->execute(); + $r = $st->get_result(); + $row = $r ? $r->fetch_assoc() : null; + $st->close(); + if($row && password_verify((string)$basicPass, $row['password_hash'])){ + unset($row['password_hash']); + $context['mode'] = 'basic'; + $context['user'] = $row; + return $context; + } + } + } + } + + if(isset($_SESSION['jwt'])){ + $payload = jwt_verify($_SESSION['jwt']); + if($payload && isset($payload['sub']) && $conn){ + $uid = intval($payload['sub']); + $st = $conn->prepare('SELECT id, email, name, organization, role, status FROM users WHERE id = ? LIMIT 1'); + if($st){ + $st->bind_param('i', $uid); + $st->execute(); + $r = $st->get_result(); + $row = $r ? $r->fetch_assoc() : null; + $st->close(); + if($row){ + $context['mode'] = 'jwt'; + $context['user'] = $row; + return $context; + } + } + } + } + + return $context; +} + +function get_current_user_info(){ + $context = get_auth_context(); + return $context['user']; +} + +function require_auth($allowedModes = ['jwt','basic','internal']){ + $context = get_auth_context(); + $user = $context['user']; + if(!$user){ auth_json_response(401, ['success'=>false,'error'=>'unauthenticated']); } + if(isset($user['status']) && $user['status'] !== 'active'){ + auth_json_response(403, ['success'=>false,'error'=>'account_not_active']); + } + if($allowedModes && $context['mode'] && !in_array($context['mode'], (array)$allowedModes, true)){ + auth_json_response(403, ['success'=>false,'error'=>'forbidden']); + } + return $user; +} + +function require_login(){ + return require_auth(['jwt','basic','internal']); +} + +function require_bearer_login(){ + $context = get_auth_context(); + if(!$context['user'] || $context['mode'] !== 'jwt'){ + auth_json_response(401, ['success'=>false,'error'=>'bearer_required']); + } + if(isset($context['user']['status']) && $context['user']['status'] !== 'active'){ + auth_json_response(403, ['success'=>false,'error'=>'account_not_active']); + } + return $context['user']; +} + +function require_role($roles){ + $user = require_login(); + // Allow admin to have universal access regardless of requested roles + if(isset($user['role']) && $user['role'] === 'admin'){ + return $user; + } + if(!in_array($user['role'], (array)$roles, true)){ + auth_json_response(403, ['success'=>false,'error'=>'forbidden']); + } + return $user; +} + +function auth_current_mode(){ + $context = get_auth_context(); + return $context['mode']; +} + +function auth_issue_user_token($userId, $role, $extra = [], $ttlSeconds = 86400){ + return jwt_issue(array_merge(['sub' => intval($userId), 'role' => $role], $extra), $ttlSeconds); +} + +function auth_set_session_jwt($token){ + start_session_if_needed(); + $_SESSION['jwt'] = $token; +} + +function auth_clear_session(){ + start_session_if_needed(); + foreach(['jwt','user_id'] as $key){ if(isset($_SESSION[$key])) unset($_SESSION[$key]); } +} diff --git a/src/config/config.php b/src/config/config.php new file mode 100644 index 0000000..b5b21f1 --- /dev/null +++ b/src/config/config.php @@ -0,0 +1,49 @@ + diff --git a/src/config/koneksi.php b/src/config/koneksi.php new file mode 100644 index 0000000..c89c4e1 --- /dev/null +++ b/src/config/koneksi.php @@ -0,0 +1,13 @@ +connect_error) { + die("Koneksi gagal: " . $conn->connect_error); +} +?> diff --git a/tambah_jalan.php b/tambah_jalan.php new file mode 100644 index 0000000..64f5e09 --- /dev/null +++ b/tambah_jalan.php @@ -0,0 +1,29 @@ +false,"error"=>"invalid_json"]); + exit; +} + +$nama = isset($data['nama']) ? $conn->real_escape_string($data['nama']) : ''; +$status = isset($data['status']) ? $conn->real_escape_string($data['status']) : ''; +$panjang = isset($data['panjang']) ? floatval($data['panjang']) : 0; +$geom = isset($data['geom']) ? $conn->real_escape_string(json_encode($data['geom'])) : ''; + +$sql = "INSERT INTO jalan (nama,status,panjang,geom) VALUES ('$nama','$status','$panjang','$geom')"; + +if($conn->query($sql) === TRUE){ + $insert_id = $conn->insert_id; + echo json_encode(["success"=>true,"id"=>$insert_id]); +} else { + http_response_code(500); + echo json_encode(["success"=>false,"error"=>$conn->error]); +} + +?> \ No newline at end of file diff --git a/tambah_lokasi.php b/tambah_lokasi.php new file mode 100644 index 0000000..a931ce1 --- /dev/null +++ b/tambah_lokasi.php @@ -0,0 +1,24 @@ +real_escape_string($data['nama']) : ''; +$no_telp = isset($data['no_telp']) ? $conn->real_escape_string($data['no_telp']) : ''; +$buka = isset($data['buka_24_jam']) ? intval($data['buka_24_jam']) : 0; +$alamat = isset($data['alamat']) ? $conn->real_escape_string($data['alamat']) : ''; +$jenis = isset($data['jenis']) ? $conn->real_escape_string($data['jenis']) : ''; +$lat = isset($data['latitude']) ? $data['latitude'] : ''; +$lng = isset($data['longitude']) ? $data['longitude'] : ''; + +// Insert specifying columns (ensure your `lokasi` table has `jenis` and `alamat` columns) +$sql = "INSERT INTO lokasi (id, nama, jenis, no_telp, buka_24_jam, alamat, latitude, longitude) VALUES ('$id', '$nama', '$jenis', '$no_telp', '$buka', '$alamat', '$lat', '$lng')"; +$res = $conn->query($sql); + +if($res){ + echo json_encode(["success"=>true, "id"=>$id]); +} else { + echo json_encode(["success"=>false, "error"=> $conn->error]); +} +?> \ No newline at end of file diff --git a/tambah_tanah.php b/tambah_tanah.php new file mode 100644 index 0000000..7bc6e45 --- /dev/null +++ b/tambah_tanah.php @@ -0,0 +1,32 @@ +false,"error"=>"invalid_json"]); + exit; +} + +$nama = isset($data['nama']) ? $conn->real_escape_string($data['nama']) : ''; +$status = isset($data['status']) ? $conn->real_escape_string($data['status']) : ''; +$luas = isset($data['luas']) ? floatval($data['luas']) : 0; +$geom = isset($data['geom']) ? $conn->real_escape_string(json_encode($data['geom'])) : ''; + +$sql = "INSERT INTO tanah (nama,status,luas,geom) VALUES ('$nama','$status','$luas','$geom')"; + +if($conn->query($sql) === TRUE){ + $insert_id = $conn->insert_id; + echo json_encode(["success"=>true,"id"=>$insert_id]); + // log + @file_put_contents('/tmp/webgis_debug.log', date('c') . " tambah_tanah OK id=".$insert_id." payload=".json_encode($data)."\n", FILE_APPEND); +} else { + http_response_code(500); + echo json_encode(["success"=>false,"error"=>$conn->error]); + @file_put_contents('/tmp/webgis_debug.log', date('c') . " tambah_tanah ERR=".$conn->error." sql=".$sql." payload=".json_encode($data)."\n", FILE_APPEND); +} + +?> \ No newline at end of file diff --git a/update_jalan.php b/update_jalan.php new file mode 100644 index 0000000..b8b3f8e --- /dev/null +++ b/update_jalan.php @@ -0,0 +1,23 @@ + 0){ + $sql = "UPDATE jalan SET " . implode(",", $sets) . " WHERE id='".$id."'"; + $conn->query($sql); +} + +echo json_encode(["msg"=>"ok"]); +?> \ No newline at end of file diff --git a/update_lokasi.php b/update_lokasi.php new file mode 100644 index 0000000..194eca8 --- /dev/null +++ b/update_lokasi.php @@ -0,0 +1,22 @@ +real_escape_string($data['id']) : ''; +$nama = isset($data['nama']) ? $conn->real_escape_string($data['nama']) : ''; +$no_telp = isset($data['no_telp']) ? $conn->real_escape_string($data['no_telp']) : ''; +$buka = isset($data['buka_24_jam']) ? intval($data['buka_24_jam']) : 0; +$alamat = isset($data['alamat']) ? $conn->real_escape_string($data['alamat']) : ''; +$jenis = isset($data['jenis']) ? $conn->real_escape_string($data['jenis']) : ''; + + $sql = "UPDATE lokasi SET nama='$nama', no_telp='$no_telp', buka_24_jam='$buka', alamat='$alamat', jenis='$jenis' WHERE id='$id'"; + $res = $conn->query($sql); + + if($res){ + echo json_encode(["success"=>true]); + } else { + echo json_encode(["success"=>false, "error"=>$conn->error]); + } + +?> \ No newline at end of file diff --git a/update_tanah.php b/update_tanah.php new file mode 100644 index 0000000..2a57689 --- /dev/null +++ b/update_tanah.php @@ -0,0 +1,28 @@ + 0){ + $sql = "UPDATE tanah SET " . implode(",", $sets) . " WHERE id='".$id."'"; + $res = $conn->query($sql); + if($res){ + @file_put_contents('/tmp/webgis_debug.log', date('c') . " update_tanah OK id=".$id." sql=".$sql." payload=".json_encode($data)."\n", FILE_APPEND); + } else { + @file_put_contents('/tmp/webgis_debug.log', date('c') . " update_tanah ERR=".$conn->error." sql=".$sql." payload=".json_encode($data)."\n", FILE_APPEND); + } +} + +echo json_encode(["msg"=>"ok"]); +?> \ No newline at end of file diff --git a/uploads b/uploads new file mode 100644 index 0000000..0991b2f --- /dev/null +++ b/uploads @@ -0,0 +1 @@ +/home/ilham/webgis_uploads \ No newline at end of file diff --git a/webgis.sql b/webgis.sql new file mode 100644 index 0000000..d45c174 --- /dev/null +++ b/webgis.sql @@ -0,0 +1,110 @@ +-- MySQL dump 10.13 Distrib 8.0.45, for Linux (x86_64) +-- +-- Host: localhost Database: webgis +-- ------------------------------------------------------ +-- Server version 8.0.45-0ubuntu0.22.04.1 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `jalan` +-- + +DROP TABLE IF EXISTS `jalan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `jalan` ( + `id` int NOT NULL AUTO_INCREMENT, + `nama` varchar(100) DEFAULT NULL, + `status` varchar(50) DEFAULT NULL, + `panjang` double DEFAULT NULL, + `geom` text, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `jalan` +-- + +LOCK TABLES `jalan` WRITE; +/*!40000 ALTER TABLE `jalan` DISABLE KEYS */; +/*!40000 ALTER TABLE `jalan` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `lokasi` +-- + +DROP TABLE IF EXISTS `lokasi`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `lokasi` ( + `id` varchar(8) NOT NULL, + `nama` varchar(50) NOT NULL, + `no_telp` varchar(20) DEFAULT NULL, + `buka_24_jam` tinyint(1) DEFAULT NULL, + `latitude` decimal(10,6) DEFAULT NULL, + `longitude` decimal(10,6) DEFAULT NULL, + `alamat` text, + `jenis` varchar(50) NOT NULL DEFAULT 'spbu', + PRIMARY KEY (`id`), + KEY `idx_lokasi_alamat` ((left(`alamat`,255))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `lokasi` +-- + +LOCK TABLES `lokasi` WRITE; +/*!40000 ALTER TABLE `lokasi` DISABLE KEYS */; +INSERT INTO `lokasi` VALUES ('5092','Rumah','',0,-0.040882,109.335197,'Daeng Abdul Hadi, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78117, Indonesia','rumah'),('7853','Masjid Mujahiddin','',1,-0.041462,109.336345,'Mujahiddin, Jalan Mujahidin, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78121, Indonesia','masjid'),('8476','SPBU OSO MT. Haryono','',0,-0.044863,109.336726,'SPBU OSO MT. Haryono, Jalan M.T. Haryono, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78121, Indonesia','spbu'); +/*!40000 ALTER TABLE `lokasi` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `tanah` +-- + +DROP TABLE IF EXISTS `tanah`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `tanah` ( + `id` int NOT NULL AUTO_INCREMENT, + `nama` varchar(100) DEFAULT NULL, + `status` varchar(50) DEFAULT NULL, + `luas` double DEFAULT NULL, + `geom` text, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `tanah` +-- + +LOCK TABLES `tanah` WRITE; +/*!40000 ALTER TABLE `tanah` DISABLE KEYS */; +/*!40000 ALTER TABLE `tanah` ENABLE KEYS */; +UNLOCK TABLES; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2026-04-23 9:46:58