尚未收集仓库信息。
尚未检测到更新。GitHub 发布版本后会显示在这里。
制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗A Saudi SaaS platform that helps real-estate offices manage rental contracts. Offices upload the contract file they downloaded from Ejar; the app reads only dates and amounts, tracks deadlines and reminds landlords and tenants. The app never connects to Ejar and is not affiliated with it.
The UI is Arabic and right-to-left. Code, comments and commits are in English.
Node.js 20 + Express 4 + EJS templates + MySQL (mysql2) + node-cron.
No frontend framework, no build step, no ORM. The app is deployed to shared
cPanel hosting, so the dependency list is fixed; see CLAUDE.md before adding one.
npm install
DB_* values:
cp .env.example .env
npm start
Open http://localhost:3000. At startup the app logs a self-check block and
creates any missing table
(database/schema.js). It still starts if the database is unreachable;
/health reports the database status.npm run seed
npm test
Database tests are skipped unless TEST_DB_NAME names a separate, empty
database the DB_USER can write to.CREATE DATABASE aqdi CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
npm install
Copy-Item .env.example .env
notepad .env
.env set at least these (leave NODE_ENV=development):
DB_USER=root
DB_PASSWORD=your MySQL root password
DB_NAME=aqdi
SMS_PROVIDER=console
PLATFORM_ADMIN_PHONE=05XXXXXXXX
and generate JWT_SECRET and SECRET_BOX_KEY with this command (run it
twice, one value each):
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Optional, for reading uploaded contracts with AI: set CLAUDE_API_KEY
(and CLAUDE_MODEL, default claude-sonnet-4-5). Without a key the app
still runs and contracts are entered by hand.npm start and open http://localhost:3000. The self-check
block in the PowerShell window tells you if anything is missing.[SMS-CONSOLE].Step-by-step guide in Arabic: deploy/DEPLOY-CPANEL.md. Technical reference, cron lines, pre-launch checklist and rollback: DEPLOY.md.
Pushes to main are tested by .github/workflows/ci.yml; when the tests pass,
.github/workflows/deploy.yml asks cPanel (UAPI) to pull main and run
.cpanel.yml (copy, npm install --omit=dev, restart), then waits for a fresh
/health. Setup (three GitHub secrets, NODE_VERSION in .cpanel.yml),
manual runs and rollback: docs/DEPLOY_AUTO.md.
| Route | What it does |
|---|---|
GET/POST /join | Join with an invite code after the phone-code login (/login?next=/join) |
GET /landlord | Landlord dashboard: units, running contracts, deadlines, payments, needs action |
GET /landlord/contracts/:id | One contract; POST .../decision saves renew / not renew / undecided |
POST /landlord/contracts/:id/payments/:pid/confirm or /reject | Answer a tenant's "I paid" |
GET /tenant | Tenant dashboard: contract, countdown, decision deadline, payments |
POST /tenant/contracts/:id/payments/:pid/report | "I paid": the installment becomes tenant_reported, never paid |
POST /tenant/contracts/:id/requests | Rent-reduction request, only while the engine allows it |
POST /office/contracts/:id/payments/:pid/confirm or /reject | The office answers a reported payment |
POST /office/contracts/:id/requests/:rid | The office accepts or rejects a tenant request |
No new environment variables.
| Route | What it does |
|---|---|
GET /notifications | Notification center (all roles): filter by kind, 20 per page |
POST /notifications/:id/read, /:id/open, /read-all | Mark read (only your own notifications) |
GET/POST /settings/notifications | Channels, quiet hours, email, WhatsApp number (login phone or SMS code), Telegram link code |
GET /office/settings/reminders | Reminder rules, WhatsApp / Telegram settings (secrets write-only), Telegram bot link and linking steps with your own link status, "إرسال رسالة تجريبية" per channel (owner, manager) |
POST /office/settings/reminders/test/:channel | Sends a short test message to you on email, whatsapp or telegram and shows the exact result: sent, not sent because not configured (and what is missing), or failed with a safe reason (never a token). 5 per 10 minutes; audit channel.test with channel, outcome and the short error code |
POST /webhooks/telegram/:secret | Telegram bot updates (random per-office secret) |
POST /cron/run/:job | Run a job now; header X-Cron-Secret: $CRON_SECRET; answers { ok, processed } |
Environment:
| Variable | Meaning |
|---|---|
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM | Email reminders. Without SMTP_HOST or MAIL_FROM emails are skipped. (SMTP_PASSWORD / SMTP_FROM still work.) |
RUN_CRON | false turns the in-process scheduler off (use cPanel Cron Jobs instead) |
CRON_SECRET | Required by POST /cron/run/:job (and /health/detail) |
WhatsApp (Meta Cloud API, template messages) and Telegram (Bot API) settings are entered per office in the app and stored encrypted with SECRET_BOX_KEY.
The office dashboard ("آخر الإشعارات المرسلة") shows, under each notification, why a channel was not sent (not configured: SMTP missing, office channel not set, no confirmed address, switched off; or failed: provider rejected the credentials, timeout, ...) with a link to the settings. Step-by-step setup in Arabic (Telegram bot with BotFather, linking a chat, WhatsApp templates, SMTP, the test button): docs/NOTIFICATIONS.md.
cPanel Cron Jobs fallback (with RUN_CRON=false):
*/5 * * * * curl -s -X POST -H "X-Cron-Secret: YOUR_CRON_SECRET" https://yourdomain.sa/cron/run/deliver
0 4 * * * curl -s -X POST -H "X-Cron-Secret: YOUR_CRON_SECRET" https://yourdomain.sa/cron/run/reminders
Jobs: reminders, deliver, recompute, late_payments, digest, expire_invites, trial_check, plan_renewal, purge_notifications, purge_auth plus backup (02:00), sms_balance (10:00), health_ping (every 15 minutes) and reports (monthly), see Operations below.
| Route | What it does |
|---|---|
GET /office/maintenance, /:id | Office board: filters, status flow (new, seen, in_progress, done, rejected), assign, internal notes, public replies |
POST /office/maintenance/:id/status, /assign, /messages | Office actions |
GET /tenant/maintenance, POST /tenant/contracts/:id/maintenance | Tenant request (category, 500 chars, priority, up to 3 photos, multipart) |
GET /landlord/maintenance, /:id, POST /landlord/maintenance/:id/messages | Landlord follows requests on their units and may comment |
GET /maintenance/photos/:id | Authenticated photo route (ownership checked, 404 otherwise) |
GET /office/payments, /office/payments.csv | Overdue list with filters and CSV |
POST /office/contracts/:id/payments/:pid/entries, /entries/:eid/undo | Record a (partial) payment; undo within 24 h with a reason |
POST /landlord/contracts/:id/payments/:pid/entries, /entries/:eid/undo | Same for the landlord (undo only their own) |
GET /office|landlord|tenant/contracts/:id/receipt | Printable payment statement of a contract |
GET /office|landlord|tenant/messages, /messages/:contractId | Per-contract thread; POST sends (10/min), /delete (author, 5 min), office /mute |
GET /office/team, POST /office/team/invite, /invites/:id/revoke, /:memberId/role|deactivate|activate | Staff invites by phone, roles, deactivation (owner and managers) |
GET /office/tasks, /:id, POST /office/tasks, /:id, /:id/status, /:id/comments | Internal Kanban tasks |
GET /office/reports, /office/reports/csv/:name | Reports (occupancy, expiring, overdue, collections, maintenance, workload) and CSV |
The unit page lists that unit's maintenance requests; the contract page says plainly that the contract file is not stored. No page is a placeholder any more (tests/noPlaceholders.test.js fails on placeholder views or "قيد البناء" text).
Storage: maintenance photos are re-encoded with sharp (JPEG, at most 1600 px, no metadata) and stored in UPLOAD_DIR (default storage/uploads in the app folder). It must be outside the public folder and writable by the app; back it up with the database. See deploy/DEPLOY-CPANEL.md step 12. plans.max_photos limits photos per office; plans.max_members limits active team members.
CSV: UTF-8 with BOM, cells starting with = + - @ get a leading quote, 10 downloads per minute per person.
Plans, the subscription lifecycle, checkout (Moyasar test mode only, or bank transfer), promo codes, invoices and the /admin area.
| Route | What it does |
|---|---|
GET /office/billing | Owner only: current plan and state, usage against the limits, plans to buy, open bank transfers, invoices |
GET /office/billing/checkout?plan=&interval=&promo= | Review: price, discount, VAT and total before paying; promo code and interval switch |
POST /office/billing/orders | Creates the order (quote, promo reservation, downgrade check in one transaction) |
GET /office/billing/orders/:id/pay | Moyasar hosted form (this page has its own Content-Security-Policy) |
GET /office/billing/moyasar/callback | The customer's return: the payment is fetched from Moyasar by id; the URL's status is ignored |
POST /webhooks/moyasar | Webhook: wrong or missing secret_token answers 404; only the payment id is used and the payment is fetched; replays are no-ops |
GET|POST /office/billing/orders/:id/transfer, GET /office/billing/receipts/:id | Bank transfer: reference (never an IBAN or a long number) and an optional receipt image (re-encoded, owner and platform admin only) |
GET /office/billing/invoices/:id | Printable A4 invoice or receipt (ownership checked, 404 otherwise) |
/admin | Platform admin (2FA, every change needs a reason and writes an audit row): overview, offices (extend trial, change plan, suspend, unsuspend, notes), orders and payments, credit notes, bank-transfer queue, promo codes, plans, settings and kill switches, audit viewer |
Environment (Moyasar, test mode only). MOYASAR_SECRET_KEY, MOYASAR_PUBLISHABLE_KEY and MOYASAR_WEBHOOK_SECRET come from the environment only. Without the first two the payment UI says "الدفع الإلكتروني غير مفعّل" and bank transfer still works. Live keys are refused unless MOYASAR_ALLOW_LIVE=1 (do not set it yet). Card data never touches this app: the customer types it into Moyasar's hosted form; we keep only the payment id, status, amount in halalas and the last 4 digits when Moyasar returns them. Webhook URL to enter in Moyasar: <APP_URL>/webhooks/moyasar, with MOYASAR_WEBHOOK_SECRET as the secret token. The API shapes used (payment fetch, form settings, webhook body) are written from Moyasar's public documentation and must be verified once in test mode before launch.
Money. VAT lives in config/billing.js (15%). Prices are stored VAT-exclusive; everything is integer halalas; VAT is rounded half up once on the discounted net. Order of math: subtotal, minus discount, VAT on the net, total.
Lifecycle. A new office gets a trial (14 days) and a trialing subscription row. A paid period that ends gives 7 days of read-only access ("وضع القراءة فقط"), then the office is suspended; data is kept 90 days (a policy; nothing is deleted automatically). An expired trial is locked at once (as before). Renewing while the same plan runs starts the new period where the old one ends; a plan change or a lapsed plan starts from the payment day. A downgrade is refused while usage is above the new limits (the message lists what to reduce; nothing is deleted). Plan changes in /admin/plans apply to subscribers at once.
Cron. plan_renewal (daily 06:00 Riyadh, also POST /cron/run/plan_renewal): moves statuses, reminds the owner 7, 3 and 1 days before the last day (trials too), and expires unpaid orders (24 h for cards, 72 h for transfers that were never sent), freeing their promo reservations. Idempotent (dedupe keys), notifications go through the notification service.
Invoices. Gap-free numbers per series and Riyadh year (INV-2026-000001, credit notes CN-2026-000001) from a locked counter row. Each invoice is a snapshot (seller, buyer = office display name only, lines, discount, VAT, total). The title is "فاتورة ضريبية مبسطة" only when the seller VAT number is set in /admin/settings; otherwise "إيصال دفع". The refund action issues a credit note; it does not move money.
Invoicing entity pending, ZATCA later. The company that issues invoices is not decided yet: legal name, VAT number, address and CR are empty-safe platform settings (platform admin only) and no company or VAT number is hardcoded. This app does not claim ZATCA e-invoicing compliance; ZATCA Phase 2 integration is a separate later task.
Default plan. An office with plan_id NULL (created before any plan existed) is read as being on the default plan: the trial plan when it is active, otherwise the cheapest active plan (services/defaultPlan.js). At start-up ensureSchema (and node database/seed.js) store that plan once and create the matching subscription row (idempotent, one transaction per office, audit action office.plan_backfill). New offices get the same default plan.
Approved pricing. Basic 149 / Pro 349 / Enterprise 799 SAR a month (a year = 10 months), VAT-exclusive; trial 14 days with Pro limits; above 600 units a custom plan via the contact form. The start-up migration moves the old placeholder plans to these values without overwriting admin edits. /pricing shows the plans with a monthly/yearly toggle (CSS only), the office settings page shows the plan, its limits and usage, and /admin/launch checks «الباقات والأسعار». Table and enforced vs stored-only limits: docs/PRICING.md.
Plan features. plans.features holds on/off switches (whatsapp, telegram, reports_csv, ai_reading); a missing key, null or the older array form means allowed. They are enforced on the server (AI reading, CSV downloads, WhatsApp/Telegram settings and delivery). max_members is the "max staff" limit.
Kill switches (/admin/settings): disable signups (existing users keep signing in), disable AI reading, and a banner message shown on every page.
| Route | What it does |
|---|---|
GET /, /features, /pricing, /about, /privacy, /terms, /disclaimer | Marketing pages (pricing is read from the plans table) |
GET/POST /contact | Contact form, stored for the platform admin (5 per IP per hour) |
GET /listings, /listings/:id | Public search (12 per page) and detail; expired, rented or hidden answer 410, drafts 404 |
POST /listings/:id/inquiry, GET/POST /listings/:id/report | Inquiry (honeypot, 5 per IP and 10 per listing per hour) and abuse report |
GET /listings/photos/:id/:variant | Listing photo (full or thumb), only while the listing is public |
GET /blog, /blog/:slug, /blog/:slug/cover, /blog/feed.xml | Blog and RSS |
GET /sitemap.xml, /robots.txt | SEO (private areas are disallowed) |
/office/listings... | Office side: create from a vacant unit, edit, photos, publish, hide, renew, inquiries (plan feature listings, limit max_listings) |
/admin/reports, /admin/messages, /admin/blog, POST /admin/settings/analytics | Platform admin: abuse queue, contact messages, blog CRUD, optional analytics tag |
Public pages show type, city, neighborhood (fixed list), rooms, area, annual rent, features, photos and the office name only.
Listings hide themselves 60 days after publishing (jobs listings_expiry, purge_inquiries). Seed the default plans, FAQs and three draft blog posts once with node database/seed.js.
No new environment variables. The final audit is in docs/AUDIT.md; the full deployment reference and pre-launch checklist in DEPLOY.md.
/<slug>)Each office has a public page at <APP_URL>/<slug> (phase 1: a path on the platform's own domain; no custom domains). It shows only the office name, city, an optional description (up to 300 characters, plain text, no phone numbers, emails or links) and the office's published listings; visitors contact the office only through each listing's inquiry form.
| Route | What it does |
|---|---|
GET /<slug> | The office page. Mounted last, after every other router. Unknown slugs, a page switched off, a suspended office or an expired trial fall through to the normal 404. An old slug or another letter case answers 301 to the current link. Cached 60 s, 120 lookups per IP per minute, noindex while the office has no published listing; pages with listings are in /sitemap.xml |
POST /office/settings/link | Change the slug (owner and manager, settings.office): lower-case a-z, 0-9 and single hyphens, 3-40 characters, unique across current and old slugs, not a reserved word (every mounted top-level path plus a fixed list) and nothing that looks official (ejar, rega, moj, nafath, absher, balady, ...). 3 changes per 30 days; the old slug is kept in office_slug_history and redirects |
POST /office/settings/public-page | The switch "إظهار صفحة المكتب للعامة" and the description |
New offices get an automatic office-xxxxxx slug with the page switched off; existing offices get one from the start-up migration (ensureSchema, idempotent). Changes are audit-logged (office.public_slug, office.public_page; never the description text). tests/officeLink.test.js fails when a mounted top-level path is missing from the reserved list. The link uses APP_URL; /admin/launch warns while it is not a public https domain.
| Route / command | What it does |
|---|---|
job backup (daily 02:00) | Encrypted database backup in BACKUP_DIR (pure Node, no mysqldump), 14 daily + 8 weekly + 6 monthly kept |
node scripts/restore-backup.js --file F --target DB | Restore drill: checks the file, restores into a database, compares every table's row count |
GET /healthz | { "ok": true } for uptime monitors, nothing else |
GET /admin/ops, POST /admin/ops/backup | Platform admin: health, backups list, "run backup now" (3 per hour), SMS balance, monthly reports |
jobs health_ping, sms_balance, reports | Health check every 15 minutes, SMS balance daily 10:00, monthly summary on the 1st at 05:00 |
Environment (all optional): BACKUP_DIR, BACKUP_WEBHOOK_URL, HEALTHCHECK_PING_URL, SMS_BALANCE_WARN. Backups cannot be downloaded from the app; see DEPLOY.md for the restore steps and the quarterly drill.
AUTH_EMAIL_TEST=true (default off) replaces the phone form on /login and /register with an email form that signs in without a code: login gives an existing user with that email the normal session (same cookie, role routing, inactive check and the owner's 2FA step); register creates a user with no role who continues on /office/new (trial plan). users.email is unique and stored lower-case; users.phone is nullable for such accounts. Safety: never for platform_admin (phone + 2FA through /login?method=phone), a red banner "وضع تجريبي: الدخول بالبريد بدون رمز" on every page, 10 attempts per IP per 15 minutes, audit rows auth.email_login / auth.email_register (outcome only, never the address), a FAIL on /admin/launch, and the server refuses to start with NODE_ENV=production unless LAUNCH_MODE=testing. Give an existing phone account an email with node scripts/set-user-email.js <phone> <email>. Steps in DEPLOY.md.
GET /admin/launch (platform admin only) shows 23 pass / warn / fail checks for opening the platform to real users (environment, secrets strength, 2FA, backups and restore drill, seller details, offices without a plan (warn), plans and prices, Ejar rules verification, lawyer review, SMTP test, Moyasar keys, APP_URL/HSTS, office public links domain, cron, directories, npm audit date, email test login off). Secret values are never shown. Hand confirmations need a reason and are audit-logged; in production the failing check ids are logged at start-up.
| Path | Purpose |
|---|---|
server.js | Startup file (Passenger/cPanel): runs the self-check, then listens |
app.js | Express app: security headers, parsers, static files, maintenance guard, routes, error handlers |
config/db.js | MySQL pool and ensureSchema(), run at startup |
database/ | schema.js (all tables, in creation order) and seed.js (default rows) |
routes/ | HTTP routes |
middleware/ | Auth, permissions, maintenance guard, security headers, 404 and error handlers |
services/ | Business logic: auth, OTP, TOTP, SMS drivers, self-check, office scoping, audit |
deploy/ | cPanel guide (Arabic) and restart.js (npm run restart) |
utils/ | Logger and time helpers |
views/ | EJS layout, partials, pages and error pages |
public/ | Static CSS/JS served with fingerprinted URLs |
tests/ | node --test test files |
reference/ | Schema and security tests from an earlier prototype, for reference only |
The project rules, roles, stack constraints and the fixed list of table names
live in CLAUDE.md. Read it before making changes.
GET /landlord/statement, /landlord/statement/csv | Landlord's read-only statement |
GET /office/tenants | Office tenants (capability tenants): one row per contract that was not replaced by a renewal, with the tenant nickname, unit, contract stage and join state (joined, invited, not invited); search, filter, 20 per page |
GET /office/audit | Read-only audit log of the office's own actions (capability audit: owner and manager): who (nickname or masked phone, and role), action, target, time in Riyadh; 30 per page; platform-admin rows and before/after values are never shown |