diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 0000000..0308bda --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,161 @@ +name: UI Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ui-tests-apna_slot-${{ github.event.number || github.ref }} + cancel-in-progress: true + +jobs: + ui-tests: + runs-on: ubuntu-latest + timeout-minutes: 60 + name: Playwright E2E Tests + + services: + redis-cache: + image: redis:alpine + ports: + - 13000:6379 + redis-queue: + image: redis:alpine + ports: + - 11000:6379 + mariadb: + image: mariadb:11.8 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + check-latest: true + + - name: Add to Hosts + run: echo "127.0.0.1 apnaslot.test" | sudo tee -a /etc/hosts + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }} + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT + + - name: Cache yarn + uses: actions/cache@v4 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/package.json') }} + + - name: Install MariaDB Client + run: | + sudo apt update + sudo apt-get install mariadb-client + + - name: Setup Bench + run: | + pip install frappe-bench + bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + + - name: Install App + working-directory: /home/runner/frappe-bench + run: | + bench get-app apna_slot $GITHUB_WORKSPACE + bench setup requirements --dev + bench new-site --db-root-password root --admin-password admin apnaslot.test + bench --site apnaslot.test install-app apna_slot + bench build + env: + CI: "Yes" + + - name: Configure Site + working-directory: /home/runner/frappe-bench + run: | + bench --site apnaslot.test set-config allow_tests true + bench --site apnaslot.test set-config host_name "http://apnaslot.test:8000" + bench --site apnaslot.test set-config mute_emails 1 + + - name: Seed Test Data + working-directory: /home/runner/frappe-bench + run: | + bench --site apnaslot.test execute frappe.utils.password.update_password --args "['Administrator', 'admin']" + bench --site apnaslot.test execute apna_slot.e2e_seed.setup_e2e_data + + - name: Start Frappe Server + working-directory: /home/runner/frappe-bench + run: | + sed -i 's/^watch:/# watch:/g' Procfile + sed -i 's/^schedule:/# schedule:/g' Procfile + bench start &> bench_start.log & + echo "Waiting for Frappe server to start..." + timeout 60 bash -c 'until curl -s http://apnaslot.test:8000 > /dev/null; do sleep 2; done' + echo "Frappe server is ready!" + + - name: Install Playwright + run: | + cd $GITHUB_WORKSPACE + npm install + npx playwright install --with-deps chromium + + - name: Run Playwright Tests + working-directory: ${{ github.workspace }} + run: npx playwright test + env: + BASE_URL: http://apnaslot.test:8000 + SITE_HOST: apnaslot.test:8000 + FRAPPE_USER: Administrator + FRAPPE_PASSWORD: admin + + - name: Upload Playwright Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-results + path: test-results/ + retention-days: 7 + + - name: Show Bench Logs on Failure + if: failure() + working-directory: /home/runner/frappe-bench + run: | + echo "=== Bench Start Log ===" + cat bench_start.log || true + echo "" + echo "=== Frappe Logs ===" + cat logs/*.log || true diff --git a/.gitignore b/.gitignore index 20cebb5..2c448f7 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,11 @@ jspm_packages/ apna_slot/public/frontend/ apna_slot/www/v.html +# Playwright +e2e/.auth/ +playwright-report/ +test-results/ + # frappe-ui vite plugin auto-generated type declarations frontend/auto-imports.d.ts frontend/components.d.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1dfa40b..ff20337 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,12 @@ Create specs in `specs/`. Maintain a `PROGRESS.md` file to track progress of imp ## Testing -Use agent-browser to test e2e. +Use agent-browser for quick manual e2e checks. + +Automated e2e uses Playwright (root `package.json`, specs in `e2e/`). Locally: +`bench --site apnaslot.localhost execute apna_slot.e2e_seed.setup_e2e_data`, then from the +app root `npm install && npx playwright install chromium && npm run test:e2e`. CI runs the +same flow via `.github/workflows/ui-tests.yml`. ## Credentials diff --git a/PROGRESS.md b/PROGRESS.md index 3756635..a0df110 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,11 +10,24 @@ model / mechanisms are in [`specs/00-architecture.md`](specs/00-architecture.md) | Phase | Slice | Status | |---|---|---| | **0** | [Tracer bullet](specs/phase-0-tracer-bullet.md) — end-to-end thread + slot lock | ✅ | -| **1** | [Publisher venue management](specs/phase-1-publisher-venue-management.md) | ⬜ | -| **2** | [Public venue page & availability](specs/phase-2-public-venue-availability.md) | ⬜ | -| **3** | [Booking, locking & mock payment](specs/phase-3-booking-locking-payment.md) | ⬜ | -| **4** | [Notifications, reviews & dashboard](specs/phase-4-notifications-reviews-dashboard.md) — MVP complete | ⬜ | -| **5** | [Roadmap / deferred](specs/phase-5-roadmap-deferred.md) | ⏸ post-MVP | +| **1** | [Publisher venue management](specs/phase-1-publisher-venue-management.md) | ✅ | +| **2** | [Public venue page & availability](specs/phase-2-public-venue-availability.md) | ✅ | +| **3** | [Booking, locking & mock payment](specs/phase-3-booking-locking-payment.md) | ✅ | +| **4** | [Notifications, reviews & dashboard](specs/phase-4-notifications-reviews-dashboard.md) — MVP complete | ✅ | +| **5** | [Post-MVP roadmap](specs/phase-5-roadmap-deferred.md) — sequenced into 5.1–5.8 | 🔄 | + +### Phase 5 sub-phases (ready for implementation) + +| Sub-phase | Slice | Depends on | Status | +|---|---|---|---| +| **5.1** | Marketplace browse & search | MVP | ✅ | +| **5.2** | Customer account area — "My Bookings" | MVP | ⬜ | +| **5.3** | Additional notification channels | Phase 4 email trigger | ⬜ | +| **5.4** | Real payment gateway | MVP | ⬜ | +| **5.5** | Customer self-cancellation & refunds | 5.4 | ⬜ | +| **5.6** | Platform monetization / commission | 5.4 | ⬜ | +| **5.7** | Per-resource custom schedules | MVP | ⬜ | +| **5.8** | Operational hardening | as needed | ⬜ | --- @@ -30,40 +43,47 @@ Thinnest thread; validates the spine (slot-lock + server pricing) before widenin - [x] Tests: slot-gen unit, hold→confirm integration, slots_taken race, agent-browser e2e - [x] Acceptance: seeded venue books one slot → Confirmed → slot shows unavailable -## Phase 1 — Publisher venue management ⬜ - -- [ ] Full Venue + Venue Resource + all child tables (gallery, exceptions, pricing rules) -- [ ] Venue Publisher role + signup handler -- [ ] `venue.py`: slug de-dupe, publish/unpublish state machine, seed 7 weekday rows -- [ ] `permissions.py` + hooks: `venue_query`, `venue_resource_query`, `has_permission` -- [ ] Publisher SPA: `/app` (My Venues), `/app/venues/:name` (editor) -- [ ] Tests: slug uniqueness, publish guards, cross-publisher isolation -- [ ] Acceptance (see spec) - -## Phase 2 — Public venue page & availability ⬜ - -- [ ] `compute_price` full surcharge rules · `get_slots_for` exceptions + midnight-cross + past-filter -- [ ] `get_public_venue` full payload · `get_slots` venue-Published/resource-Active guards -- [ ] Public `/v/:slug`: hero, gallery, rules, resource + date pickers, multi-select slot cart -- [ ] Tests: pricing math, closed/special-hours, past-slot exclusion, unpublished 404 -- [ ] Acceptance (see spec) - -## Phase 3 — Booking, locking & mock payment ⬜ - -- [ ] `booking.py` / `booking_order.py` controllers (amount, slot_key, status sync) -- [ ] `hold_slots` multi-slot all-or-nothing · `confirm_payment` · `create_account_from_order` -- [ ] `tasks.py::expire_holds` scheduler (cron `* * * * *`) + hooks -- [ ] Checkout `/v/:slug/book`, confirmation `/v/:slug/confirmed/:order`, 10-min countdown -- [ ] Tests: single + multi happy path, all-or-nothing, concurrency race, expiry -- [ ] Acceptance (see spec) - -## Phase 4 — Notifications, reviews & dashboard ⬜ - -- [ ] Confirmation emails (one per order) via `doc_events` / enqueue, dedupe on transition -- [ ] Venue Review DocType + `create_review` (gated) + `avg_rating`/`review_count` in `get_public_venue` -- [ ] `get_publisher_dashboard` + `/app/dashboard` + `/app/bookings` -- [ ] Tests: one-review-per-booking, dashboard scoping, email-on-confirm-only -- [ ] Acceptance (see spec) → **MVP complete** +## Phase 1 — Publisher venue management ✅ + +- [x] Full Venue + Venue Resource + all child tables (gallery, exceptions, pricing rules); tab/column breaks +- [x] Venue Publisher role (pre-model-sync patch) + `become_publisher` self-service onboarding +- [x] `venue.py`: slug de-dupe, publish/unpublish state machine, seed 7 weekday rows +- [x] `permissions.py` + hooks: `venue_query`, `venue_resource_query`, `has_permission` +- [x] Publisher SPA: `/manage` (My Venues), `/manage/venues/:name` (editor, 6 tabs) +- [x] Tests: slug uniqueness, publish guards, cross-publisher isolation (11 new, 17 total green) +- [x] Acceptance: website user → publisher → create → edit → add resource → publish → public page shows it (agent-browser e2e) + +## Phase 2 — Public venue page & availability ✅ + +- [x] `compute_price` full surcharge rules · `get_slots_for` exceptions + midnight-cross + past-filter +- [x] `get_public_venue` full payload · `get_slots` venue-Published/resource-Active guards +- [x] Public `/v/:slug`: hero, gallery, rules, resource + date pickers, multi-select slot cart +- [x] Tests: pricing math, closed/special-hours, past-slot exclusion, unpublished 404 (13 new, 30 total green) +- [x] Acceptance: weekend-evening price stacking, Closed day msg, Special Hours override, unpublished "not available" (agent-browser e2e) + +## Phase 3 — Booking, locking & mock payment ✅ + +- [x] `booking.py` (validate derives end_time + amount via pricing + slot_key) / `booking_order.py` + (`recompute_total`, `sync_status` keeps lines in lockstep with the order) +- [x] `hold_slots` multi-slot all-or-nothing (+ `taken` list) · `confirm_payment` (guards) · + `create_account_from_order` · `get_order` (confirmation payload) +- [x] `tasks.py::expire_holds` scheduler (cron `* * * * *`) wired in `hooks.py` +- [x] Checkout `/v/:slug/book`, mock gateway + 10-min countdown, confirmation `/v/:slug/confirmed/:order` +- [x] Tests: single + multi happy path, all-or-nothing (no partial order), held-slot lock, duplicate + reject, expiry frees slots, confirm-after-expiry reject, account linking (7 new, 37 total green) +- [x] Acceptance: multi-slot select → hold → mock pay → Confirmed; both slots lock, third stays free; + account links `customer` (agent-browser e2e green) + +## Phase 4 — Notifications, reviews & dashboard ✅ + +- [x] Confirmation emails (one per order) via `doc_events` Booking Order `on_update` + `enqueue`, + guarded to the real → Confirmed transition (best-effort send) +- [x] Venue Review DocType + `create_review` (gated) + `avg_rating`/`review_count`/`reviews` in `get_public_venue` +- [x] `get_publisher_dashboard` + `/manage/dashboard` + `/manage/bookings`; Booking/Booking Order/ + Venue Review permission scoping (`*_query` + `has_*_permission`) + Venue Publisher read perms +- [x] Tests: one-review-per-booking, dashboard scoping, email-on-confirm-only (12 new, 49 total green) +- [x] Acceptance: venue page shows avg/reviews; confirmation review form posts once; dashboard + + bookings scoped to the logged-in publisher (agent-browser e2e green) → **MVP complete** --- @@ -71,6 +91,102 @@ Thinnest thread; validates the spine (slot-lock + server pricing) before widenin _Newest first. One line per meaningful step (spec committed, phase started/shipped, decisions)._ +- **2026-07-08** — Phase 5.1 QA review resolved (all 4 findings). `search_venues` caps + `page_length` at 50 and computes avg/count via one grouped `_review_summaries` query + (`Avg`/`Count` `GROUP BY venue`) instead of a per-venue N+1; `get_all` uses `limit=` (drops the + deprecated `page_length=`); card payload no longer carries the unused `currency`. `Browse.vue` + debounces the search query (300ms), loads city immediately, and drops stale responses via a + monotonic token. 9/9 `test_public_api` green; grouped avg verified live; frontend rebuilt. +- **2026-07-08** — Phase 5.1 shipped (thinnest slice) — **marketplace browse**. Backend: + `search_venues(city, q, start, page_length)` + `list_cities()` in `api/public.py` + (`allow_guest`, Published-only, card payload via `_venue_card`; `or_filters` text search over + name/city; reuses `_review_summary` for avg/count — no `publisher`/draft leak). `home_page = "v"` + now serves the SPA at the site root. Frontend: `Browse.vue` (card grid + city dropdown + search) + + `browse/VenueCard.vue`; vue-router `/` → Browse. 4 new tests (53 total green); agent-browser + e2e green (`/` lists 6 Published venues → Mumbai filter → 1 card → opens `/v/sunrise-sports-club`; + drafts never surface). Deviations: (1) `category`/`date` filters + pagination UI + separate + `/search` route **deferred** to the next widening — the single `/` page does browse+search+city + (tracer bullet was "one filter live"); no venue-level `category` field exists (it's on Venue + Resource). (2) frappe-ui `FormControl type="select"` renders a **button-combobox** (not native + `` (not frappe-ui + `DatePicker`) — reliable min-date + automation; (2) `/v/:slug/book` is a **stub** (real checkout is + Phase 3) reading `history.state.selection`; (3) `prose` classes no-op (no `@tailwindcss/typography` + installed) — rich-text still renders correctly; (4) rebuilt SPA is gitignored — run + `yarn --cwd frontend build`. +- **2026-07-07** — Phase 1 shipped. Full Venue schema (7 new fields + gallery/exceptions/pricing + child tables, tab/column breaks) + Venue Resource `category`/`sort_order`; 3 new child DocTypes; + `Venue Publisher` role via pre-model-sync patch; `venue.py` (slug de-dupe, seed weekdays, + publish/unpublish guards); `permissions.py` (`venue_query`/`venue_resource_query` + `has_permission`) + wired in hooks; `api/publisher.py` (get_current_publisher / become_publisher / publish_venue / + unpublish_venue); publisher SPA at `/manage` (`ManageShell`, `MyVenues`, `VenueEditor` + 6 tab + components). 11 new tests (17 total green); agent-browser e2e passes end-to-end. + Deviations: (1) publisher app at **`/manage`** not `/app` — `/app` is Frappe Desk; one www page + (`v.html`) serves both `/v` and `/manage`, vue-router dispatches by path. (2) frappe-ui 0.1.278: + `Dialog` uses `v-model` + `:options.title` (not `v-model:open`); imperative confirm is + `confirmDialog` (not `dialog.confirm`), needs `` mounted; no `frappe-ui/list` subpath + so lists are `divide-y` rows. (3) doc I/O via imperative `call('frappe.client.get|save|insert| + delete', …)` — note it's `frappe.client.get`, there is no `get_doc`. (4) publish/unpublish + wrappers accept `str | int` (autoincrement `name` arrives as a number → strict type-annotated + args otherwise 417). (5) description/rules use `textarea` (rich `Editor` deferred). (6) + lightweight top-bar `ManageShell` instead of full `DesktopShell` (2-page app). - **2026-07-07** — Phase 0 shipped. 6 DocTypes + `slot_key` unique index; `engine/slots.py` + `engine/pricing.py`; guest `api/public.py` (get_public_venue / get_slots / hold_slots / confirm_payment); Vue SPA at `/v/:slug`. 6 tests green; agent-browser e2e passes diff --git a/README.md b/README.md index 966e845..5df136a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,203 @@ -### Apna Slot +# ApnaSlot -Slot booking made simple +Slot booking made simple. ApnaSlot is a Frappe app that lets venue owners publish +their turfs, courts and halls online, and lets customers browse, pick a time slot and +pay, all in one place. -### Installation +A venue owner (called a **publisher**) sets up weekly hours and prices once. Customers +find the venue, see live prices per slot, hold one or more slots and pay through a mock +gateway. The booking confirms only after payment, and a slot can never be booked twice. -You can install this app using the [bench](https://github.com/frappe/bench) CLI: +## Table of Contents + +- [Who it is for](#who-it-is-for) +- [Features](#features) +- [Screenshots](#screenshots) + - [For customers](#for-customers) + - [For venue owners](#for-venue-owners) +- [How it works](#how-it-works) + - [No double booking](#no-double-booking) + - [Server side pricing](#server-side-pricing) + - [Ten minute hold](#ten-minute-hold) +- [Data model](#data-model) +- [Tech stack](#tech-stack) +- [Project structure](#project-structure) +- [Installation](#installation) +- [Running the app](#running-the-app) +- [Testing](#testing) +- [Roadmap](#roadmap) +- [License](#license) + +## Who it is for + +- **Customers** browse venues, check availability and book slots. No account is needed + to book. They can create an account after paying if they want to. +- **Publishers** (venue owners) manage their venues, hours, prices and resources, and + watch bookings and earnings from a dashboard. + +## Features + +- Public marketplace to browse venues by city or name. +- Venue page with cover image, rules, gallery, multiple resources (for example Court A, + Turf 1) and a live slot grid with prices. +- Multi slot cart. Pick several slots and pay once. +- Server side pricing with base price plus weekend and peak hour surcharges. +- Slot locking so the same slot can never be double booked. +- Ten minute hold on selected slots, freed automatically if payment does not complete. +- Mock payment gateway (built to be swapped for a real one later). +- Confirmation email to the customer and the publisher. +- Reviews and star ratings, allowed only after a confirmed booking. +- Publisher dashboard with today's bookings, weekly and monthly revenue, and a per + venue breakdown. +- Publisher venue editor with tabs for details, resources, weekly hours, pricing rules, + gallery and publish status. +- Role based access so a publisher only ever sees their own venues and bookings. + +## Screenshots + +### For customers + +**Marketplace.** Customers land on the home page and browse published venues. They can +search by name or filter by city. + +![Home page listing venue cards with name, city and starting price, plus a search box and a city filter](docs/images/01-marketplace.png) + +**Venue page.** Each venue shows a cover, address, contact, rules and its resources. +Pick a resource and a date to see the slots. Prices change for peak hours. Here the +evening slots cost more than the daytime ones. + +![Venue page for Sunrise Sports Club showing rules, a resource tab bar, a date picker and a grid of time slots with prices](docs/images/02-venue-page.png) + +**Selecting slots.** Tapping slots adds them to a cart at the bottom of the page. The +cart shows every slot, the total price and a book button. + +![Two time slots selected and highlighted, with a bottom cart showing both slots and a total of one thousand three hundred rupees](docs/images/03-selection-cart.png) + +**Checkout.** The customer reviews the slots and enters name, phone and an optional +email. Email is only needed if they want an account later. + +![Checkout page listing the selected slots, the total, and a form for name, phone and email](docs/images/04-checkout.png) + +**Payment.** A mock gateway confirms the amount. A timer shows how long the slots stay +held before they are released. + +![Mock payment screen showing the amount, a hold countdown timer and a pay button](docs/images/05-mock-gateway.png) + +**Confirmation.** After paying, the customer sees the confirmed order and can leave a +star rating and a comment. + +![Booking confirmed page with the order number, the paid slots, and a rate your experience form with stars and a comment box](docs/images/06-confirmation.png) + +### For venue owners + +**My Venues.** A publisher sees a list of their own venues with each venue's city, +resource count and publish status. + +![My Venues list showing several venues, each with a city, resource count and a published badge](docs/images/07-my-venues.png) + +**Dashboard.** Quick numbers at the top (today's bookings, this week's bookings, weekly +and monthly revenue), an upcoming bookings list and a per venue breakdown. + +![Publisher dashboard with four stat tiles, an upcoming bookings list and a per venue revenue breakdown](docs/images/08-dashboard.png) + +**Bookings.** All bookings grouped by order, with filters for venue and status. + +![Bookings page grouped by order number, each showing the customer, slots, amount and a confirmed badge](docs/images/09-bookings.png) + +**Venue editor.** Six tabs to set up a venue: details, resources, availability, pricing, +gallery and publish. + +![Venue editor on the details tab showing venue name, city, phone, base price, address and rules fields](docs/images/10-venue-editor.png) + +**Weekly hours.** On the availability tab the publisher sets open and close times and +the slot length for each weekday, plus one off date exceptions. + +![Availability tab showing open and close times and slot length for each day of the week, and a date exceptions section](docs/images/11-availability-tab.png) + +## How it works + +### No double booking + +Every active booking (held or confirmed) writes a `slot_key` made of the resource, the +date and the start time. This key has a unique index in the database. If two people try +to hold the same slot at the same time, the second write fails and that request loses +the race cleanly. When a booking is cancelled or expires, its key is rewritten so the +slot is free again. + +A multi slot order is all or nothing. If any one slot in the order is already taken, the +whole order rolls back and no partial booking is left behind. + +### Server side pricing + +The client never sends a price. The server computes it on every hold: + +``` +price(slot) = base_price + sum of matching surcharge rules +``` + +A rule matches on day type (all days, weekdays or weekends) and a time band. For +example, base 500 rupees, a weekend rule of plus 200, and a peak rule of plus 300 for +6 pm to 10 pm makes a Saturday 7 pm slot cost 1000 rupees. + +### Ten minute hold + +When slots are held, the order gets a ten minute expiry. A scheduled job runs every +minute, finds holds that have passed their expiry, marks them expired, frees the slots +and fails the pending payment. So abandoned checkouts never block a slot for long. + +## Data model + +| DocType | Purpose | +|---|---| +| Venue | A place with hours, prices, gallery and rules. Owned by a publisher. | +| Venue Resource | A bookable unit inside a venue (for example Court A, Turf 1). | +| Venue Weekly Availability | Open and close time and slot length per weekday (child table). | +| Venue Availability Exception | Date level override, either closed or special hours (child table). | +| Venue Pricing Rule | A surcharge for a day type and time band (child table). | +| Venue Gallery | Extra images for a venue (child table). | +| Booking Order | Groups one or more slots into one checkout and one payment. | +| Booking | One slot line inside an order. Holds the `slot_key` lock. | +| Payment Transaction | One payment per order. Mock now, real gateway later. | +| Venue Review | A star rating and comment, tied to one confirmed booking. | + +For the full schema, the locking mechanism and the pricing rules, see +[`specs/00-architecture.md`](specs/00-architecture.md). + +## Tech stack + +- **Backend:** Frappe Framework (Python). DocTypes, whitelisted APIs, permission hooks + and a scheduled job. +- **Frontend:** One Vite plus Vue 3 single page app built with the + [frappe-ui](https://github.com/frappe/frappe-ui) component library and its design + tokens. +- **Database:** MariaDB (a unique index on `slot_key` enforces no double booking). +- **Tests:** Python unit and integration tests, plus Playwright end to end tests. + +## Project structure + +``` +apna_slot/ + apna_slot/ + api/ Guest and publisher APIs (public.py, publisher.py) + engine/ Pricing and slot generation (pricing.py, slots.py) + doctype/ The ten DocTypes listed above + permissions.py Query and single doc permission rules + tasks.py Scheduled hold expiry job + notifications.py Confirmation emails + tests/ Python tests + frontend/ Vite + Vue 3 + frappe-ui single page app + e2e/ Playwright end to end tests + specs/ Design specs, one per build phase + PROGRESS.md Phase by phase build status +``` + +The frontend serves two route groups. Public routes live under `/v` (browse, venue page, +checkout, confirmation). Publisher routes live under `/manage` (venues, editor, bookings, +dashboard) and need a Frappe login. + +## Installation + +Install with the [bench](https://github.com/frappe/bench) CLI: ```bash cd $PATH_TO_YOUR_BENCH @@ -12,29 +205,56 @@ bench get-app $URL_OF_THIS_REPO --branch main bench install-app apna_slot ``` -### Contributing +Build the frontend (the built assets are not committed): -This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository: +```bash +yarn --cwd apps/apna_slot/frontend build +``` + +## Running the app + +- Public site root serves the marketplace at `/`. +- A venue page is at `/v/`, for example `/v/sunrise-sports-club`. +- The publisher area is at `/manage` and needs a login. + +To develop the frontend with hot reload: ```bash cd apps/apna_slot -pre-commit install +yarn dev ``` -Pre-commit is configured to use the following tools for checking and formatting your code: +## Testing + +**Python tests** (unit and integration, 53 tests today): + +```bash +bench --site apnaslot.localhost run-tests --app apna_slot +``` + +**End to end tests** (Playwright). First seed the demo data, then run: + +```bash +bench --site apnaslot.localhost execute apna_slot.e2e_seed.setup_e2e_data +cd apps/apna_slot +npm install && npx playwright install chromium && npm run test:e2e +``` -- ruff -- eslint -- prettier -- pyupgrade -### CI +CI runs the same flow on every push. -This app can use GitHub Actions for CI. The following workflows are configured: +## Roadmap -- CI: Installs this app and runs unit tests on every push to `develop` branch. -- Linters: Runs [Frappe Semgrep Rules](https://github.com/frappe/semgrep-rules) and [pip-audit](https://pypi.org/project/pip-audit/) on every pull request. +The core booking product (phases 0 to 4) and the marketplace browse (phase 5.1) are +done. Planned next steps, tracked in [`PROGRESS.md`](PROGRESS.md): +- Customer account area with a "My Bookings" view. +- More notification channels beyond email. +- A real payment gateway in place of the mock one. +- Customer self cancellation and refunds. +- Platform commission and monetization. +- Per resource custom schedules. +- Operational hardening. -### License +## License -mit +MIT diff --git a/apna_slot/api/public.py b/apna_slot/api/public.py index 680c10d..a8bd06c 100644 --- a/apna_slot/api/public.py +++ b/apna_slot/api/public.py @@ -7,11 +7,13 @@ import frappe from frappe import _ -from frappe.utils import add_to_date, now_datetime +from frappe.query_builder.functions import Avg, Count +from frappe.utils import add_to_date, get_datetime, now_datetime from apna_slot.engine.slots import get_slots_for HOLD_MINUTES = 10 +MAX_PAGE_LENGTH = 50 # cap guest listing size — an allow_guest endpoint must not amplify @frappe.whitelist(allow_guest=True) @@ -24,55 +26,239 @@ def get_public_venue(slug: str): resources = frappe.get_all( "Venue Resource", filters={"venue": name, "status": "Active"}, - fields=["name", "resource_name"], - order_by="creation asc", + fields=["name", "resource_name", "category"], + order_by="sort_order asc, creation asc", ) return { - "venue": { - "name": venue.name, - "venue_name": venue.venue_name, - "slug": venue.slug, - "base_price": venue.base_price, - "currency": venue.currency, - }, + "venue": _public_venue_payload(venue), "resources": resources, } +def _public_venue_payload(venue): + """Only fields safe for guests — never ``publisher`` or draft-only data.""" + reviews = _review_summary(venue.name) + return { + "name": venue.name, + "venue_name": venue.venue_name, + "slug": venue.slug, + "city": venue.city, + "address": venue.address, + "description": venue.description, + "rules": venue.rules, + "cover_image": venue.cover_image, + "contact_phone": venue.contact_phone, + "base_price": venue.base_price, + "currency": venue.currency, + "gallery": [{"image": row.image, "caption": row.caption} for row in venue.gallery], + **reviews, + } + + +STARS = 5 # Rating is stored as a 0-1 fraction; the UI speaks in 1-5 stars. + + +def _review_summary(venue_name): + """Average rating (stars), count, and the latest Published reviews.""" + rows = frappe.get_all( + "Venue Review", + filters={"venue": venue_name, "status": "Published"}, + fields=["reviewer_name", "rating", "comment", "creation"], + order_by="creation desc", + ) + average = sum(row.rating for row in rows) / len(rows) if rows else 0 + return { + "avg_rating": round(average * STARS, 1), + "review_count": len(rows), + "reviews": [ + { + "reviewer_name": row.reviewer_name or "Guest", + "rating": round(row.rating * STARS), + "comment": row.comment, + } + for row in rows[:20] + ], + } + + +@frappe.whitelist(allow_guest=True) +def search_venues( + city: str | None = None, + q: str | None = None, + start: int = 0, + page_length: int = 20, +): + """Marketplace listing — Published venues only, in card shape. + + One live filter (city) plus text search over name/city; leaks no + ``publisher`` or draft data. Category/date filters widen this later. + """ + filters = {"status": "Published"} + if city: + filters["city"] = city + venues = frappe.get_all( + "Venue", + filters=filters, + or_filters=_text_filters(q), + fields=["name", "venue_name", "slug", "city", "cover_image", "base_price"], + order_by="venue_name asc", + start=int(start), + limit=min(int(page_length), MAX_PAGE_LENGTH), + ) + summaries = _review_summaries([venue.name for venue in venues]) + return [_venue_card(venue, summaries.get(venue.name)) for venue in venues] + + +def _text_filters(q): + if not q: + return None + like = f"%{q}%" + return [["venue_name", "like", like], ["city", "like", like]] + + +def _review_summaries(venue_names): + """avg_rating (stars) + review_count for a page of venues — one grouped query, no N+1.""" + if not venue_names: + return {} + review = frappe.qb.DocType("Venue Review") + rows = ( + frappe.qb.from_(review) + .select(review.venue, Avg(review.rating).as_("avg"), Count(review.name).as_("count")) + .where(review.venue.isin(venue_names) & (review.status == "Published")) + .groupby(review.venue) + ).run(as_dict=True) + return { + row.venue: {"avg_rating": round(float(row.avg or 0) * STARS, 1), "review_count": row.count} + for row in rows + } + + +def _venue_card(venue, summary): + summary = summary or {"avg_rating": 0, "review_count": 0} + return { + "slug": venue.slug, + "venue_name": venue.venue_name, + "city": venue.city, + "cover_image": venue.cover_image, + "base_price": venue.base_price, + "avg_rating": summary["avg_rating"], + "review_count": summary["review_count"], + } + + +@frappe.whitelist(allow_guest=True) +def list_cities(): + """Distinct cities across Published venues — powers the browse filter.""" + return frappe.get_all( + "Venue", + filters={"status": "Published", "city": ["is", "set"]}, + distinct=True, + pluck="city", + order_by="city asc", + ) + + +@frappe.whitelist(allow_guest=True) +def create_review(booking: str, rating: int | float | str, comment: str | None = None): + """Post one review for a Confirmed booking the caller owns (guest allowed, gated).""" + stars = _valid_stars(rating) + order = _assert_reviewable(booking) + review = frappe.new_doc("Venue Review") + review.booking = booking + review.customer = order.customer if order.customer and order.customer != "Guest" else None + review.reviewer_name = order.customer_name + review.rating = stars / STARS + review.comment = comment + review.insert(ignore_permissions=True) + return {"ok": True, "review": review.name} + + +def _valid_stars(rating): + stars = int(float(rating)) + if stars < 1 or stars > STARS: + frappe.throw(_("Please give a rating between 1 and {0} stars.").format(STARS)) + return stars + + +def _assert_reviewable(booking): + line = frappe.db.get_value("Booking", booking, ["name", "order", "status"], as_dict=True) + if not line or line.status != "Confirmed": + frappe.throw(_("Only a confirmed booking can be reviewed.")) + if frappe.db.exists("Venue Review", {"booking": booking}): + frappe.throw(_("This booking has already been reviewed.")) + order = frappe.get_doc("Booking Order", line.order) + _assert_owns_order(order) + return order + + +def _assert_owns_order(order): + """A logged-in reviewer must own the order; guests review only via its link.""" + user = frappe.session.user + if user == "Guest": + return + if order.customer == user or (order.customer_email and order.customer_email == user): + return + if is_own_email(user, order.customer_email): + return + frappe.throw(_("This booking belongs to another account."), frappe.PermissionError) + + +def is_own_email(user, order_email): + return bool(order_email) and frappe.db.get_value("User", user, "email") == order_email + + @frappe.whitelist(allow_guest=True) def get_slots(resource: str, date: str): + _assert_public_resource(resource) return get_slots_for(resource, date) +def _assert_public_resource(resource): + """Reject slots for inactive resources or non-Published venues (no data leak).""" + row = frappe.db.get_value("Venue Resource", resource, ["venue", "status"], as_dict=True) + if not row or row.status != "Active": + frappe.throw(_("This resource is not available."), frappe.DoesNotExistError) + if frappe.db.get_value("Venue", row.venue, "status") != "Published": + frappe.throw(_("This venue is not available."), frappe.DoesNotExistError) + + @frappe.whitelist(allow_guest=True) def hold_slots(venue: str | int, slots: list | str, customer: dict | str): """Hold one or more slots as a single all-or-nothing Booking Order. - Phase 0 sends a single-element ``slots`` list, but the list shape is the - contract from day one. On a slot-key collision the whole order rolls back - and ``{ok: False, reason: "slots_taken"}`` is returned. + Validates every slot is legal, then in one transaction creates the order and + one Booking line per slot. The first line to hit the ``slot_key`` unique index + rolls back the whole order and returns ``{ok: False, reason: "slots_taken"}`` — + no partial order is ever created. """ slots = frappe.parse_json(slots) customer = frappe.parse_json(customer) + _assert_venue_published(venue) + _assert_no_duplicates(slots) + for slot in slots: + _assert_slot_legal(venue, slot) + frappe.db.savepoint("hold_slots") try: order = _create_order(venue, customer) - total = _create_lines(order, venue, slots) + _create_lines(order, venue, slots) except frappe.UniqueValidationError: frappe.db.rollback(save_point="hold_slots") - return {"ok": False, "reason": "slots_taken"} + return {"ok": False, "reason": "slots_taken", "taken": _taken_slots(slots)} - payment = _create_payment(order, total) - order.db_set("total_amount", total) + order.recompute_total() + payment = _create_payment(order) order.db_set("payment", payment.name) return { "ok": True, "order": order.name, "payment": payment.name, - "total_amount": total, + "lines": order.line_names(), + "total_amount": order.total_amount, "hold_expires_at": order.hold_expires_at, + "expires_in": HOLD_MINUTES * 60, } @@ -80,27 +266,122 @@ def hold_slots(venue: str | int, slots: list | str, customer: dict | str): def confirm_payment(payment: str): """Mock gateway: mark the payment Success and confirm the order + all lines.""" pay = frappe.get_doc("Payment Transaction", payment) + order = frappe.get_doc("Booking Order", pay.order) + _assert_confirmable(pay, order) + pay.status = "Success" pay.paid_on = now_datetime() pay.gateway_ref = f"MOCK-{pay.name}" pay.save(ignore_permissions=True) - order = frappe.get_doc("Booking Order", pay.order) - order.status = "Confirmed" - order.save(ignore_permissions=True) - for line in frappe.get_all("Booking", filters={"order": order.name}, pluck="name"): - booking = frappe.get_doc("Booking", line) - booking.status = "Confirmed" - booking.save(ignore_permissions=True) - + order.sync_status("Confirmed", confirmed_on=now_datetime()) return {"ok": True, "order": order.name, "status": order.status} +# Confirmation page reads its own order by id (order name is the guest bearer token). +@frappe.whitelist(allow_guest=True) # nosemgrep +def get_order(order: str): + """Safe order summary for the confirmation page (guest-readable).""" + doc = frappe.get_doc("Booking Order", order) + venue = frappe.db.get_value("Venue", doc.venue, ["venue_name", "slug"], as_dict=True) + lines = frappe.get_all( + "Booking", + filters={"order": doc.name}, + fields=["name", "resource", "booking_date", "start_time", "end_time", "amount", "status"], + order_by="booking_date asc, start_time asc", + ) + reviewed = set( + frappe.get_all("Venue Review", {"booking": ["in", [l.name for l in lines]]}, pluck="booking") + ) + for line in lines: + line["resource_name"] = frappe.db.get_value("Venue Resource", line.resource, "resource_name") + line["reviewed"] = line.name in reviewed + return { + "order": doc.name, + "status": doc.status, + "total_amount": doc.total_amount, + "customer_name": doc.customer_name, + "customer_email": doc.customer_email, + "has_account": bool(doc.customer), + "venue": venue, + "lines": lines, + } + + +@frappe.whitelist(allow_guest=True) +def create_account_from_order(order: str, password: str): + """Optionally turn a guest order's email into a Website User (post-booking).""" + doc = frappe.get_doc("Booking Order", order) + if not doc.customer_email: + frappe.throw(_("This order has no email to create an account with.")) + user = _ensure_website_user(doc.customer_email, doc.customer_name, password) + doc.db_set("customer", user) + return {"ok": True, "user": user} + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +def _assert_venue_published(venue): + if frappe.db.get_value("Venue", venue, "status") != "Published": + frappe.throw(_("This venue is not available."), frappe.DoesNotExistError) + + +def _assert_no_duplicates(slots): + seen = set() + for slot in slots: + key = (slot["resource"], slot["date"], slot["start"]) + if key in seen: + frappe.throw(_("The same slot was selected more than once.")) + seen.add(key) + + +def _assert_slot_legal(venue, slot): + _assert_public_resource(slot["resource"]) + if str(frappe.db.get_value("Venue Resource", slot["resource"], "venue")) != str(venue): + frappe.throw(_("Selected slot is not available.")) + if not _slot_of(slot): + frappe.throw(_("Selected slot is not available.")) + + +def _slot_of(slot): + for candidate in get_slots_for(slot["resource"], slot["date"]): + if candidate["start"] == slot["start"]: + return candidate + return None + + +def _taken_slots(slots): + return [slot for slot in slots if not _slot_available(slot)] + + +def _slot_available(slot): + found = _slot_of(slot) + return bool(found and found["available"]) + + +def _assert_confirmable(pay, order): + if pay.status != "Pending": + frappe.throw(_("This payment has already been processed.")) + if order.status != "Held": + frappe.throw(_("This order can no longer be paid for.")) + if get_datetime(order.hold_expires_at) < now_datetime(): + frappe.throw(_("This hold has expired. Please re-select your slots.")) + + +# --------------------------------------------------------------------------- +# Builders +# --------------------------------------------------------------------------- + + def _create_order(venue, customer): order = frappe.new_doc("Booking Order") order.venue = venue order.customer_name = customer.get("name") order.customer_phone = customer.get("phone") + order.customer_email = customer.get("email") order.status = "Held" order.hold_expires_at = add_to_date(now_datetime(), minutes=HOLD_MINUTES) order.insert(ignore_permissions=True) @@ -108,35 +389,35 @@ def _create_order(venue, customer): def _create_lines(order, venue, slots): - total = 0 for slot in slots: - resolved = _resolve_slot(slot) booking = frappe.new_doc("Booking") booking.order = order.name booking.venue = venue booking.resource = slot["resource"] booking.booking_date = slot["date"] booking.start_time = slot["start"] - booking.end_time = resolved["end"] booking.status = "Held" - booking.amount = resolved["price"] - booking.insert(ignore_permissions=True) # slot_key unique index enforces the lock - total += resolved["price"] - return total + booking.insert(ignore_permissions=True) # controller sets end_time/amount/slot_key -def _resolve_slot(slot): - for candidate in get_slots_for(slot["resource"], slot["date"]): - if candidate["start"] == slot["start"]: - return candidate - frappe.throw(_("Selected slot is not available.")) - - -def _create_payment(order, total): +def _create_payment(order): payment = frappe.new_doc("Payment Transaction") payment.order = order.name - payment.amount = total + payment.amount = order.total_amount payment.gateway = "Mock" payment.status = "Pending" payment.insert(ignore_permissions=True) return payment + + +def _ensure_website_user(email, full_name, password): + if frappe.db.exists("User", email): + return email + user = frappe.new_doc("User") + user.email = email + user.first_name = full_name or email + user.user_type = "Website User" + user.new_password = password + user.send_welcome_email = 0 + user.insert(ignore_permissions=True) + return user.name diff --git a/apna_slot/api/publisher.py b/apna_slot/api/publisher.py new file mode 100644 index 0000000..b96a970 --- /dev/null +++ b/apna_slot/api/publisher.py @@ -0,0 +1,149 @@ +"""Publisher-facing endpoints: onboarding + venue publish state machine. + +Venue/Resource reads and writes go through standard permissioned DocType access +(frappe-ui ``useList`` / ``call('frappe.client.*')``); only the actions that need +server logic live here. +""" + +import frappe +from frappe import _ +from frappe.query_builder.functions import Sum +from frappe.utils import get_first_day, get_first_day_of_week, get_fullname, getdate + +PUBLISHER_ROLE = "Venue Publisher" + + +@frappe.whitelist() +def get_current_publisher() -> dict: + user = frappe.session.user + if user == "Guest": + return {"authenticated": False} + return { + "authenticated": True, + "user": user, + "full_name": get_fullname(user), + "is_publisher": PUBLISHER_ROLE in frappe.get_roles(user), + } + + +@frappe.whitelist() +def become_publisher() -> dict: + user = frappe.session.user + if user == "Guest": + frappe.throw(_("Please log in to continue."), frappe.PermissionError) + user_doc = frappe.get_doc("User", user) + if PUBLISHER_ROLE not in [row.role for row in user_doc.roles]: + user_doc.append("roles", {"role": PUBLISHER_ROLE}) + user_doc.save(ignore_permissions=True) + return {"ok": True, "is_publisher": True} + + +@frappe.whitelist() +def get_publisher_dashboard() -> dict: + """Aggregates scoped to the caller's own venues (Confirmed bookings only).""" + user = _require_publisher() + venues = _my_venues(user) + if not venues: + return _empty_dashboard() + today = getdate() + return { + "bookings_today": _bookings_count(venues, {"booking_date": today}), + "bookings_week": _bookings_count(venues, {"booking_date": [">=", get_first_day_of_week(today)]}), + "revenue_week": _revenue(venues, get_first_day_of_week(today)), + "revenue_month": _revenue(venues, get_first_day(today)), + "upcoming": _upcoming_bookings(venues, today), + "by_venue": _venue_breakdown(venues, get_first_day(today)), + } + + +def _require_publisher() -> str: + user = frappe.session.user + if user == "Guest": + frappe.throw(_("Please log in to continue."), frappe.PermissionError) + if PUBLISHER_ROLE not in frappe.get_roles(user): + frappe.throw(_("You are not a venue publisher."), frappe.PermissionError) + return user + + +def _my_venues(user: str) -> list[str]: + return frappe.get_all("Venue", filters={"publisher": user}, pluck="name") + + +def _empty_dashboard() -> dict: + return { + "bookings_today": 0, + "bookings_week": 0, + "revenue_week": 0, + "revenue_month": 0, + "upcoming": [], + "by_venue": [], + } + + +def _confirmed(venues: list[str], extra: dict) -> dict: + return {"venue": ["in", venues], "status": "Confirmed", **extra} + + +def _bookings_count(venues: list[str], extra: dict) -> int: + return frappe.db.count("Booking", _confirmed(venues, extra)) + + +def _revenue(venues: list[str], since) -> float: + booking = frappe.qb.DocType("Booking") + total = ( + frappe.qb.from_(booking) + .select(Sum(booking.amount)) + .where(booking.venue.isin(venues)) + .where(booking.status == "Confirmed") + .where(booking.booking_date >= since) + ).run()[0][0] + return total or 0 + + +def _upcoming_bookings(venues: list[str], today) -> list[dict]: + rows = frappe.get_all( + "Booking", + filters=_confirmed(venues, {"booking_date": [">=", today]}), + fields=["name", "order", "venue", "resource", "booking_date", "start_time", "end_time", "amount"], + order_by="booking_date asc, start_time asc", + limit=25, + ) + _enrich(rows) + return rows + + +def _enrich(rows: list[dict]) -> None: + for row in rows: + row.venue_name = frappe.db.get_value("Venue", row.venue, "venue_name") + row.resource_name = frappe.db.get_value("Venue Resource", row.resource, "resource_name") + row.customer_name = frappe.db.get_value("Booking Order", row.order, "customer_name") + + +def _venue_breakdown(venues: list[str], month_start) -> list[dict]: + breakdown = [] + for venue in venues: + breakdown.append( + { + "venue": venue, + "venue_name": frappe.db.get_value("Venue", venue, "venue_name"), + "bookings": _bookings_count([venue], {}), + "revenue_month": _revenue([venue], month_start), + } + ) + return breakdown + + +@frappe.whitelist() +def publish_venue(venue: str | int) -> dict: + doc = frappe.get_doc("Venue", venue) + doc.check_permission("write") + doc.publish() + return {"status": doc.status, "slug": doc.slug, "published_on": doc.published_on} + + +@frappe.whitelist() +def unpublish_venue(venue: str | int) -> dict: + doc = frappe.get_doc("Venue", venue) + doc.check_permission("write") + doc.unpublish() + return {"status": doc.status} diff --git a/apna_slot/apna_slot/doctype/booking/booking.json b/apna_slot/apna_slot/doctype/booking/booking.json index 78d1cee..00ac10c 100644 --- a/apna_slot/apna_slot/doctype/booking/booking.json +++ b/apna_slot/apna_slot/doctype/booking/booking.json @@ -109,6 +109,11 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "read": 1, + "report": 1, + "role": "Venue Publisher" } ], "sort_field": "modified", diff --git a/apna_slot/apna_slot/doctype/booking/booking.py b/apna_slot/apna_slot/doctype/booking/booking.py index 8ab6b62..8a05a3a 100644 --- a/apna_slot/apna_slot/doctype/booking/booking.py +++ b/apna_slot/apna_slot/doctype/booking/booking.py @@ -2,17 +2,60 @@ from frappe.model.document import Document from frappe.utils import get_time, getdate +from apna_slot.engine.pricing import compute_price + # Held/Confirmed hold the slot; Cancelled/Expired free it. ACTIVE_STATUSES = ("Held", "Confirmed") +WEEKDAYS = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +] +DEFAULT_DURATION = 60 +DAY_MINUTES = 24 * 60 class Booking(Document): def validate(self): + venue = frappe.get_cached_doc("Venue", self.venue) + self.set_end_time(venue) + self.set_amount(venue) self.set_slot_key() + def set_end_time(self, venue): + start = _minutes(self.start_time) + self.end_time = _format(start + self._duration(venue)) + + def set_amount(self, venue): + # Never trust a posted amount — always recompute from the pricing engine. + self.amount = compute_price(venue, self._weekday(), self.start_time) + def set_slot_key(self): date = getdate(self.booking_date).isoformat() start = get_time(self.start_time).strftime("%H:%M:%S") base = f"{self.resource}::{date}::{start}" # Inactive rows append `name` so the row keeps a value that can never collide. self.slot_key = base if self.status in ACTIVE_STATUSES else f"{base}::{self.name}" + + def _weekday(self): + return WEEKDAYS[getdate(self.booking_date).weekday()] + + def _duration(self, venue): + for row in venue.weekly_availability: + if row.weekday == self._weekday(): + return int(row.slot_duration_minutes or DEFAULT_DURATION) + return DEFAULT_DURATION + + +def _minutes(value): + time = get_time(value) + return time.hour * 60 + time.minute + + +def _format(minutes): + minutes %= DAY_MINUTES # wrap post-midnight times (24:00 → 00:00) + return f"{minutes // 60:02d}:{minutes % 60:02d}:00" diff --git a/apna_slot/apna_slot/doctype/booking_order/booking_order.json b/apna_slot/apna_slot/doctype/booking_order/booking_order.json index 4769d79..f73d3d3 100644 --- a/apna_slot/apna_slot/doctype/booking_order/booking_order.json +++ b/apna_slot/apna_slot/doctype/booking_order/booking_order.json @@ -9,9 +9,12 @@ "venue", "customer_name", "customer_phone", + "customer_email", + "customer", "status", "total_amount", "hold_expires_at", + "confirmed_on", "payment" ], "fields": [ @@ -42,6 +45,18 @@ "fieldtype": "Data", "label": "Customer Phone" }, + { + "fieldname": "customer_email", + "fieldtype": "Data", + "label": "Customer Email", + "options": "Email" + }, + { + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "User" + }, { "default": "Held", "fieldname": "status", @@ -61,6 +76,11 @@ "fieldtype": "Datetime", "label": "Hold Expires At" }, + { + "fieldname": "confirmed_on", + "fieldtype": "Datetime", + "label": "Confirmed On" + }, { "fieldname": "payment", "fieldtype": "Link", @@ -88,6 +108,11 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "read": 1, + "report": 1, + "role": "Venue Publisher" } ], "sort_field": "modified", diff --git a/apna_slot/apna_slot/doctype/booking_order/booking_order.py b/apna_slot/apna_slot/doctype/booking_order/booking_order.py index 1b67805..4338c2b 100644 --- a/apna_slot/apna_slot/doctype/booking_order/booking_order.py +++ b/apna_slot/apna_slot/doctype/booking_order/booking_order.py @@ -1,5 +1,26 @@ +import frappe from frappe.model.document import Document class BookingOrder(Document): - pass + def line_names(self): + return frappe.get_all("Booking", filters={"order": self.name}, pluck="name") + + def recompute_total(self): + amounts = frappe.get_all("Booking", filters={"order": self.name}, pluck="amount") + self.db_set("total_amount", sum(amounts)) + + def sync_status(self, status, **fields): + """Move the order and every line to `status` together (all-or-nothing lifecycle). + + Re-saving each line lets `Booking.set_slot_key` rewrite its slot_key: active + statuses keep the slot locked, inactive ones free it. + """ + self.status = status + for field, value in fields.items(): + setattr(self, field, value) + self.save(ignore_permissions=True) + for name in self.line_names(): + line = frappe.get_doc("Booking", name) + line.status = status + line.save(ignore_permissions=True) diff --git a/apna_slot/apna_slot/doctype/venue/venue.json b/apna_slot/apna_slot/doctype/venue/venue.json index 1f3a7d0..bd0bf02 100644 --- a/apna_slot/apna_slot/doctype/venue/venue.json +++ b/apna_slot/apna_slot/doctype/venue/venue.json @@ -6,14 +6,38 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "details_tab", "venue_name", + "publisher", "slug", "status", + "published_on", + "column_break_identity", + "city", + "contact_phone", "base_price", "currency", - "weekly_availability" + "about_section", + "address", + "description", + "rules", + "cover_section", + "cover_image", + "gallery_tab", + "gallery", + "availability_tab", + "weekly_availability", + "exceptions_section", + "availability_exceptions", + "pricing_tab", + "pricing_rules" ], "fields": [ + { + "fieldname": "details_tab", + "fieldtype": "Tab Break", + "label": "Details" + }, { "fieldname": "venue_name", "fieldtype": "Data", @@ -21,11 +45,20 @@ "label": "Venue Name", "reqd": 1 }, + { + "fieldname": "publisher", + "fieldtype": "Link", + "label": "Publisher", + "options": "User", + "read_only": 1, + "reqd": 1 + }, { "fieldname": "slug", "fieldtype": "Data", "in_list_view": 1, "label": "Slug", + "read_only": 1, "unique": 1 }, { @@ -34,7 +67,29 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Status", - "options": "Draft\nPublished\nUnpublished" + "options": "Draft\nPublished\nUnpublished", + "read_only": 1 + }, + { + "fieldname": "published_on", + "fieldtype": "Datetime", + "label": "Published On", + "read_only": 1 + }, + { + "fieldname": "column_break_identity", + "fieldtype": "Column Break" + }, + { + "fieldname": "city", + "fieldtype": "Data", + "in_list_view": 1, + "label": "City" + }, + { + "fieldname": "contact_phone", + "fieldtype": "Data", + "label": "Contact Phone" }, { "fieldname": "base_price", @@ -49,16 +104,84 @@ "fieldtype": "Data", "label": "Currency" }, + { + "fieldname": "about_section", + "fieldtype": "Section Break", + "label": "About" + }, + { + "fieldname": "address", + "fieldtype": "Small Text", + "label": "Address" + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description" + }, + { + "fieldname": "rules", + "fieldtype": "Text Editor", + "label": "Booking Rules" + }, + { + "fieldname": "cover_section", + "fieldtype": "Section Break", + "label": "Cover" + }, + { + "fieldname": "cover_image", + "fieldtype": "Attach Image", + "label": "Cover Image" + }, + { + "fieldname": "gallery_tab", + "fieldtype": "Tab Break", + "label": "Gallery" + }, + { + "fieldname": "gallery", + "fieldtype": "Table", + "label": "Gallery", + "options": "Venue Gallery" + }, + { + "fieldname": "availability_tab", + "fieldtype": "Tab Break", + "label": "Availability" + }, { "fieldname": "weekly_availability", "fieldtype": "Table", "label": "Weekly Availability", "options": "Venue Weekly Availability" + }, + { + "fieldname": "exceptions_section", + "fieldtype": "Section Break", + "label": "Date Exceptions" + }, + { + "fieldname": "availability_exceptions", + "fieldtype": "Table", + "label": "Availability Exceptions", + "options": "Venue Availability Exception" + }, + { + "fieldname": "pricing_tab", + "fieldtype": "Tab Break", + "label": "Pricing" + }, + { + "fieldname": "pricing_rules", + "fieldtype": "Table", + "label": "Pricing Rules", + "options": "Venue Pricing Rule" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-07-07 00:00:00", + "modified": "2026-07-07 12:00:00", "modified_by": "Administrator", "module": "Apna Slot", "name": "Venue", @@ -76,6 +199,17 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Venue Publisher", + "share": 1, + "write": 1 } ], "sort_field": "modified", diff --git a/apna_slot/apna_slot/doctype/venue/venue.py b/apna_slot/apna_slot/doctype/venue/venue.py index 4a0cbf7..53f388c 100644 --- a/apna_slot/apna_slot/doctype/venue/venue.py +++ b/apna_slot/apna_slot/doctype/venue/venue.py @@ -1,15 +1,79 @@ import re import frappe +from frappe import _ from frappe.model.document import Document +from frappe.utils import now_datetime + +WEEKDAYS = ( + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +) class Venue(Document): def validate(self): + self.set_publisher() + self.set_unique_slug() + self.seed_weekly_availability() + + def set_publisher(self): + if self.is_new() and not self.publisher: + self.publisher = frappe.session.user + + def set_unique_slug(self): if not self.slug: - self.slug = self.make_slug() + self.slug = self.make_unique_slug(self.slugify(self.venue_name)) - def make_slug(self): - # Phase 1 adds collision de-dupe; base slugify only for now. - slug = re.sub(r"[^a-z0-9]+", "-", (self.venue_name or "").lower()).strip("-") + def slugify(self, text): + slug = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-") return slug or frappe.generate_hash(length=8) + + def make_unique_slug(self, base): + slug, suffix = base, 2 + while self.slug_taken(slug): + slug = f"{base}-{suffix}" + suffix += 1 + return slug + + def slug_taken(self, slug): + filters = {"slug": slug} + if self.name: + filters["name"] = ("!=", self.name) + return bool(frappe.db.exists("Venue", filters)) + + def seed_weekly_availability(self): + if self.weekly_availability: + return + for weekday in WEEKDAYS: + self.append( + "weekly_availability", + {"weekday": weekday, "is_open": 1, "open_time": "09:00:00", "close_time": "21:00:00"}, + ) + + def publish(self): + self.ensure_publishable() + self.status = "Published" + self.published_on = now_datetime() + self.save() + + def unpublish(self): + self.status = "Unpublished" + self.save() + + def ensure_publishable(self): + if not self.has_active_resource(): + frappe.throw(_("Add at least one active resource before publishing.")) + if not self.has_open_weekday(): + frappe.throw(_("Keep at least one weekday open before publishing.")) + + def has_active_resource(self): + return bool(frappe.db.exists("Venue Resource", {"venue": self.name, "status": "Active"})) + + def has_open_weekday(self): + return any(row.is_open for row in self.weekly_availability) diff --git a/apna_slot/apna_slot/doctype/venue_availability_exception/__init__.py b/apna_slot/apna_slot/doctype/venue_availability_exception/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apna_slot/apna_slot/doctype/venue_availability_exception/venue_availability_exception.json b/apna_slot/apna_slot/doctype/venue_availability_exception/venue_availability_exception.json new file mode 100644 index 0000000..b333d65 --- /dev/null +++ b/apna_slot/apna_slot/doctype/venue_availability_exception/venue_availability_exception.json @@ -0,0 +1,56 @@ +{ + "actions": [], + "creation": "2026-07-07 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "date", + "exception_type", + "open_time", + "close_time" + ], + "fields": [ + { + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date", + "reqd": 1 + }, + { + "default": "Closed", + "fieldname": "exception_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Exception Type", + "options": "Closed\nSpecial Hours" + }, + { + "depends_on": "eval:doc.exception_type == 'Special Hours'", + "fieldname": "open_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "Open Time" + }, + { + "depends_on": "eval:doc.exception_type == 'Special Hours'", + "fieldname": "close_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "Close Time" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-07-07 00:00:00", + "modified_by": "Administrator", + "module": "Apna Slot", + "name": "Venue Availability Exception", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/apna_slot/apna_slot/doctype/venue_availability_exception/venue_availability_exception.py b/apna_slot/apna_slot/doctype/venue_availability_exception/venue_availability_exception.py new file mode 100644 index 0000000..6a14330 --- /dev/null +++ b/apna_slot/apna_slot/doctype/venue_availability_exception/venue_availability_exception.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class VenueAvailabilityException(Document): + pass diff --git a/apna_slot/apna_slot/doctype/venue_gallery/__init__.py b/apna_slot/apna_slot/doctype/venue_gallery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apna_slot/apna_slot/doctype/venue_gallery/venue_gallery.json b/apna_slot/apna_slot/doctype/venue_gallery/venue_gallery.json new file mode 100644 index 0000000..ffdd166 --- /dev/null +++ b/apna_slot/apna_slot/doctype/venue_gallery/venue_gallery.json @@ -0,0 +1,38 @@ +{ + "actions": [], + "creation": "2026-07-07 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "image", + "caption" + ], + "fields": [ + { + "fieldname": "image", + "fieldtype": "Attach Image", + "in_list_view": 1, + "label": "Image", + "reqd": 1 + }, + { + "fieldname": "caption", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Caption" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-07-07 00:00:00", + "modified_by": "Administrator", + "module": "Apna Slot", + "name": "Venue Gallery", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/apna_slot/apna_slot/doctype/venue_gallery/venue_gallery.py b/apna_slot/apna_slot/doctype/venue_gallery/venue_gallery.py new file mode 100644 index 0000000..c93997c --- /dev/null +++ b/apna_slot/apna_slot/doctype/venue_gallery/venue_gallery.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class VenueGallery(Document): + pass diff --git a/apna_slot/apna_slot/doctype/venue_pricing_rule/__init__.py b/apna_slot/apna_slot/doctype/venue_pricing_rule/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apna_slot/apna_slot/doctype/venue_pricing_rule/venue_pricing_rule.json b/apna_slot/apna_slot/doctype/venue_pricing_rule/venue_pricing_rule.json new file mode 100644 index 0000000..32fe8c5 --- /dev/null +++ b/apna_slot/apna_slot/doctype/venue_pricing_rule/venue_pricing_rule.json @@ -0,0 +1,60 @@ +{ + "actions": [], + "creation": "2026-07-07 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "day_type", + "from_time", + "to_time", + "surcharge_amount" + ], + "fields": [ + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label" + }, + { + "default": "All", + "fieldname": "day_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Day Type", + "options": "All\nWeekday\nWeekend" + }, + { + "fieldname": "from_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "From Time" + }, + { + "fieldname": "to_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "To Time" + }, + { + "fieldname": "surcharge_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Surcharge Amount" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-07-07 00:00:00", + "modified_by": "Administrator", + "module": "Apna Slot", + "name": "Venue Pricing Rule", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/apna_slot/apna_slot/doctype/venue_pricing_rule/venue_pricing_rule.py b/apna_slot/apna_slot/doctype/venue_pricing_rule/venue_pricing_rule.py new file mode 100644 index 0000000..d90148b --- /dev/null +++ b/apna_slot/apna_slot/doctype/venue_pricing_rule/venue_pricing_rule.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class VenuePricingRule(Document): + pass diff --git a/apna_slot/apna_slot/doctype/venue_resource/venue_resource.json b/apna_slot/apna_slot/doctype/venue_resource/venue_resource.json index c2cee3c..25c3a41 100644 --- a/apna_slot/apna_slot/doctype/venue_resource/venue_resource.json +++ b/apna_slot/apna_slot/doctype/venue_resource/venue_resource.json @@ -8,7 +8,9 @@ "naming_series", "resource_name", "venue", - "status" + "category", + "status", + "sort_order" ], "fields": [ { @@ -34,6 +36,12 @@ "options": "Venue", "reqd": 1 }, + { + "fieldname": "category", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Category" + }, { "default": "Active", "fieldname": "status", @@ -41,11 +49,17 @@ "in_list_view": 1, "label": "Status", "options": "Active\nInactive" + }, + { + "default": "0", + "fieldname": "sort_order", + "fieldtype": "Int", + "label": "Sort Order" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-07-07 00:00:00", + "modified": "2026-07-07 12:00:00", "modified_by": "Administrator", "module": "Apna Slot", "name": "Venue Resource", @@ -63,6 +77,17 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Venue Publisher", + "share": 1, + "write": 1 } ], "sort_field": "modified", diff --git a/apna_slot/apna_slot/doctype/venue_review/__init__.py b/apna_slot/apna_slot/doctype/venue_review/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apna_slot/apna_slot/doctype/venue_review/venue_review.json b/apna_slot/apna_slot/doctype/venue_review/venue_review.json new file mode 100644 index 0000000..fa84d13 --- /dev/null +++ b/apna_slot/apna_slot/doctype/venue_review/venue_review.json @@ -0,0 +1,115 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2026-07-08 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "naming_series", + "venue", + "booking", + "column_break_key", + "customer", + "reviewer_name", + "status", + "rating_section", + "rating", + "comment" + ], + "fields": [ + { + "default": "REV-.#####", + "fieldname": "naming_series", + "fieldtype": "Select", + "hidden": 1, + "label": "Naming Series", + "options": "REV-.#####" + }, + { + "fieldname": "venue", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Venue", + "options": "Venue", + "reqd": 1 + }, + { + "fieldname": "booking", + "fieldtype": "Link", + "label": "Booking", + "options": "Booking", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "column_break_key", + "fieldtype": "Column Break" + }, + { + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "User" + }, + { + "fieldname": "reviewer_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Reviewer Name" + }, + { + "default": "Published", + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "Published\nHidden" + }, + { + "fieldname": "rating_section", + "fieldtype": "Section Break", + "label": "Rating" + }, + { + "fieldname": "rating", + "fieldtype": "Rating", + "in_list_view": 1, + "label": "Rating", + "reqd": 1 + }, + { + "fieldname": "comment", + "fieldtype": "Small Text", + "label": "Comment" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-07-08 00:00:00", + "modified_by": "Administrator", + "module": "Apna Slot", + "name": "Venue Review", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "read": 1, + "report": 1, + "role": "Venue Publisher" + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/apna_slot/apna_slot/doctype/venue_review/venue_review.py b/apna_slot/apna_slot/doctype/venue_review/venue_review.py new file mode 100644 index 0000000..6c54e65 --- /dev/null +++ b/apna_slot/apna_slot/doctype/venue_review/venue_review.py @@ -0,0 +1,11 @@ +import frappe +from frappe.model.document import Document + + +class VenueReview(Document): + def validate(self): + self.set_venue_from_booking() + + def set_venue_from_booking(self): + if not self.venue: + self.venue = frappe.db.get_value("Booking", self.booking, "venue") diff --git a/apna_slot/e2e_seed.py b/apna_slot/e2e_seed.py new file mode 100644 index 0000000..634f48f --- /dev/null +++ b/apna_slot/e2e_seed.py @@ -0,0 +1,80 @@ +"""Seed script for E2E Playwright tests. + +Run via: bench --site execute apna_slot.e2e_seed.setup_e2e_data +""" + +import frappe + +PUBLISHER_ROLE = "Venue Publisher" +VENUE_SLUG = "e2e-arena" +VENUE_NAME = "E2E Arena" + +WEEKDAYS = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +] + + +def setup_e2e_data(): + """Create the publisher, a Published venue, and an active resource for E2E tests.""" + _make_admin_publisher() + venue = _ensure_published_venue() + _ensure_resource(venue) + # Seed script run via `bench execute`; commit persists fixtures for the test run. + frappe.db.commit() # nosemgrep + + +def _make_admin_publisher(): + """Grant Administrator the Venue Publisher role so it can own the seed venue.""" + user = frappe.get_doc("User", "Administrator") + if PUBLISHER_ROLE not in [row.role for row in user.roles]: + user.append("roles", {"role": PUBLISHER_ROLE}) + user.save(ignore_permissions=True) + + +def _ensure_published_venue() -> str: + """Create a Published venue with a stable slug and full weekly availability.""" + existing = frappe.db.get_value("Venue", {"slug": VENUE_SLUG}, "name") + if existing: + return existing + + venue = frappe.new_doc("Venue") + venue.venue_name = VENUE_NAME + venue.slug = VENUE_SLUG + venue.status = "Published" + venue.base_price = 500 + venue.publisher = "Administrator" + for day in WEEKDAYS: + venue.append( + "weekly_availability", + { + "weekday": day, + "is_open": 1, + "open_time": "06:00:00", + "close_time": "22:00:00", + "slot_duration_minutes": 60, + }, + ) + venue.insert(ignore_permissions=True) + return venue.name + + +def _ensure_resource(venue: str) -> str: + """Create an Active resource so the public slot grid has something to render.""" + existing = frappe.db.get_value( + "Venue Resource", {"venue": venue, "resource_name": "Center Court"}, "name" + ) + if existing: + return existing + + resource = frappe.new_doc("Venue Resource") + resource.resource_name = "Center Court" + resource.venue = venue + resource.status = "Active" + resource.insert(ignore_permissions=True) + return resource.name diff --git a/apna_slot/engine/pricing.py b/apna_slot/engine/pricing.py index 5bee5f7..526afc0 100644 --- a/apna_slot/engine/pricing.py +++ b/apna_slot/engine/pricing.py @@ -1,10 +1,31 @@ """Server-authoritative slot pricing. The client never posts a price.""" +from frappe.utils import get_time + +WEEKEND = ("Saturday", "Sunday") + def compute_price(venue, weekday, start_time): - """Price of one slot. + """Price of one slot: base + every matching pricing rule (architecture 3.1).""" + price = venue.base_price + for rule in venue.pricing_rules: + if _rule_matches(rule, weekday, start_time): + price += rule.surcharge_amount + return price + + +def _rule_matches(rule, weekday, start_time): + return _day_matches(rule.day_type, weekday) and _time_matches(rule, start_time) + + +def _day_matches(day_type, weekday): + if day_type == "All": + return True + if day_type == "Weekend": + return weekday in WEEKEND + return weekday not in WEEKEND # Weekday = Mon-Fri + - Phase 0: base price only. Phase 2 adds the surcharge loop over - ``venue.pricing_rules`` (day-type + time-band), per architecture 3.1. - """ - return venue.base_price +def _time_matches(rule, start_time): + start = get_time(start_time) + return get_time(rule.from_time) <= start < get_time(rule.to_time) diff --git a/apna_slot/engine/slots.py b/apna_slot/engine/slots.py index d0a124d..9d6c3bd 100644 --- a/apna_slot/engine/slots.py +++ b/apna_slot/engine/slots.py @@ -15,17 +15,19 @@ "Sunday", ] ACTIVE_STATUSES = ("Held", "Confirmed") +DAY_MINUTES = 24 * 60 def get_slots_for(resource, date): - """Return ``[{start, end, price, available}]`` from the weekly template. + """Return ``[{start, end, price, available}]`` for the resource on the date. - Phase 0: weekly template only. Phase 2 adds exceptions + midnight-cross. + Weekly template minus exceptions (Closed / Special Hours) minus active bookings, with + per-slot price, midnight-crossing hours, and past-slot exclusion. """ date = getdate(date) venue = _venue_of(resource) - row = _weekday_row(venue, date) - if not row or not row.is_open: + hours = _hours_for(venue, date) + if not hours: return [] weekday = WEEKDAYS[date.weekday()] @@ -36,7 +38,7 @@ def get_slots_for(resource, date): "price": compute_price(venue, weekday, start), "available": _is_available(resource, date, start), } - for start, end in _windows(row) + for start, end in _windows(hours) ] return _drop_past(slots, date) @@ -46,6 +48,34 @@ def _venue_of(resource): return frappe.get_cached_doc("Venue", venue_name) +def _hours_for(venue, date): + """Open/close/duration (minutes) for the date, or ``None`` when closed. + + A same-day exception wins over the weekly template: ``Closed`` shuts the day, + ``Special Hours`` overrides open/close (keeping the weekday's slot duration). + """ + exception = _exception_for(venue, date) + row = _weekday_row(venue, date) + duration = (row.slot_duration_minutes if row else None) or 60 + + if exception: + if exception.exception_type == "Closed": + return None + return _hours(exception.open_time, exception.close_time, duration) + + if not row or not row.is_open: + return None + return _hours(row.open_time, row.close_time, duration) + + +def _hours(open_time, close_time, duration): + open_m = _minutes(open_time) + close_m = _minutes(close_time) + if close_m <= open_m: + close_m += DAY_MINUTES # crosses midnight — close is next day + return {"open": open_m, "close": close_m, "step": int(duration)} + + def _weekday_row(venue, date): weekday = WEEKDAYS[date.weekday()] for row in venue.weekly_availability: @@ -54,15 +84,19 @@ def _weekday_row(venue, date): return None -def _windows(row): - open_m = _minutes(row.open_time) - close_m = _minutes(row.close_time) - step = int(row.slot_duration_minutes or 60) +def _exception_for(venue, date): + for row in venue.availability_exceptions: + if getdate(row.date) == date: + return row + return None + + +def _windows(hours): windows = [] - start = open_m - while start + step <= close_m: - windows.append((_format(start), _format(start + step))) - start += step + start = hours["open"] + while start + hours["step"] <= hours["close"]: + windows.append((_format(start), _format(start + hours["step"]))) + start += hours["step"] return windows @@ -94,4 +128,5 @@ def _minutes(value): def _format(minutes): + minutes %= DAY_MINUTES # wrap post-midnight times (24:00 → 00:00) return f"{minutes // 60:02d}:{minutes % 60:02d}:00" diff --git a/apna_slot/hooks.py b/apna_slot/hooks.py index 82b2a6c..8613d62 100644 --- a/apna_slot/hooks.py +++ b/apna_slot/hooks.py @@ -57,7 +57,8 @@ # ---------- # application home page (will override Website Settings) -# home_page = "login" +# The SPA (www/v.html) also serves the marketplace browse page at `/`. +home_page = "v" # website user home page (by Role) # role_home_page = { @@ -126,29 +127,42 @@ # ----------- # Permissions evaluated in scripted ways -# permission_query_conditions = { -# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", -# } -# -# has_permission = { -# "Event": "frappe.desk.doctype.event.event.has_permission", -# } +# Scope publishers to their own venues + everything hanging off them (System Managers unscoped). +permission_query_conditions = { + "Venue": "apna_slot.permissions.venue_query", + "Venue Resource": "apna_slot.permissions.venue_resource_query", + "Booking Order": "apna_slot.permissions.booking_order_query", + "Booking": "apna_slot.permissions.booking_query", + "Venue Review": "apna_slot.permissions.venue_review_query", +} + +has_permission = { + "Venue": "apna_slot.permissions.has_venue_permission", + "Venue Resource": "apna_slot.permissions.has_venue_resource_permission", + "Booking Order": "apna_slot.permissions.has_booking_order_permission", + "Booking": "apna_slot.permissions.has_booking_permission", + "Venue Review": "apna_slot.permissions.has_venue_review_permission", +} # Document Events # --------------- -# Hook on document methods and events - -# doc_events = { -# "*": { -# "on_update": "method", -# "on_cancel": "method", -# "on_trash": "method" -# } -# } +# Send confirmation emails on the transition to Confirmed (Phase 4). +doc_events = { + "Booking Order": { + "on_update": "apna_slot.notifications.on_booking_order_update", + }, +} # Scheduled Tasks # --------------- +# Free lapsed 10-minute holds every minute (Phase 3). +scheduler_events = { + "cron": { + "* * * * *": ["apna_slot.tasks.expire_holds"], + }, +} + # scheduler_events = { # "all": [ # "apna_slot.tasks.all" @@ -170,9 +184,12 @@ # Website # ------- -# Serve the Vue SPA for every public venue route. +# One SPA (www/v.html) serves both route groups; vue-router dispatches by path. +# `/manage` is the publisher app (login-gated in the SPA); `/v` is the public page. website_route_rules = [ {"from_route": "/v/", "to_route": "v"}, + {"from_route": "/manage/", "to_route": "v"}, + {"from_route": "/manage", "to_route": "v"}, ] # Testing @@ -266,4 +283,3 @@ # ------------ # List of apps whose translatable strings should be excluded from this app's translations. # ignore_translatable_strings_from = [] - diff --git a/apna_slot/notifications.py b/apna_slot/notifications.py new file mode 100644 index 0000000..c3e04ff --- /dev/null +++ b/apna_slot/notifications.py @@ -0,0 +1,111 @@ +"""Order confirmation emails. + +Wired in ``hooks.py`` via ``doc_events`` on Booking Order ``on_update``. Exactly +one customer email and one publisher email are queued on the transition to +``Confirmed`` — never on Held/Expired/Cancelled, and never twice. +""" + +import frappe +from frappe.utils import format_time + + +def on_booking_order_update(doc, method=None): + if _confirmed_transition(doc): + frappe.enqueue( + "apna_slot.notifications.send_confirmation_emails", + queue="short", + order=doc.name, + now=bool(frappe.flags.in_test), + ) + + +def _confirmed_transition(doc): + """True only when a previously-saved order flips to Confirmed (never on insert).""" + before = doc.get_doc_before_save() + return doc.status == "Confirmed" and bool(before) and before.status != "Confirmed" + + +def send_confirmation_emails(order): + order_doc = frappe.get_doc("Booking Order", order) + for email in build_confirmation_emails(order_doc): + # Best-effort: a mail outage must never fail an already-paid booking. + try: + frappe.sendmail(**email) + except Exception: + frappe.log_error(title=f"ApnaSlot: confirmation email failed ({order})") + + +def build_confirmation_emails(order_doc): + """One email for the customer, one for the venue publisher (both list all slots).""" + venue = frappe.get_cached_doc("Venue", order_doc.venue) + lines = _slot_lines(order_doc) + emails = [] + if order_doc.customer_email: + emails.append(_customer_email(order_doc, venue, lines)) + if venue.publisher: + emails.append(_publisher_email(order_doc, venue, lines)) + return emails + + +def _customer_email(order_doc, venue, lines): + return { + "recipients": [order_doc.customer_email], + "subject": f"Booking confirmed — {venue.venue_name} ({order_doc.name})", + "message": frappe.render_template(_CUSTOMER_TEMPLATE, _context(order_doc, venue, lines)), + } + + +def _publisher_email(order_doc, venue, lines): + return { + "recipients": [venue.publisher], + "subject": f"New booking — {venue.venue_name} ({order_doc.name})", + "message": frappe.render_template(_PUBLISHER_TEMPLATE, _context(order_doc, venue, lines)), + } + + +def _slot_lines(order_doc): + rows = frappe.get_all( + "Booking", + filters={"order": order_doc.name}, + fields=["resource", "booking_date", "start_time", "end_time", "amount"], + order_by="booking_date asc, start_time asc", + ) + for row in rows: + row.resource_name = frappe.db.get_value("Venue Resource", row.resource, "resource_name") + row.start = format_time(row.start_time, "HH:mm") + row.end = format_time(row.end_time, "HH:mm") + return rows + + +def _context(order_doc, venue, lines): + return {"order": order_doc, "venue": venue, "lines": lines} + + +_CUSTOMER_TEMPLATE = """ +

Hi {{ order.customer_name or "there" }},

+

Your booking at {{ venue.venue_name }} is confirmed. Order {{ order.name }}.

+
    +{% for line in lines %} +
  • {{ line.resource_name }} — {{ line.booking_date }}, {{ line.start }}-{{ line.end }} + (₹{{ "{:,.0f}".format(line.amount or 0) }})
  • +{% endfor %} +
+

Total paid: ₹{{ "{:,.0f}".format(order.total_amount or 0) }}

+{% if venue.address %}

Address: {{ venue.address }}

{% endif %} +{% if venue.rules %}

Venue rules:

{{ venue.rules }}{% endif %} +

See you there!

+""" + +_PUBLISHER_TEMPLATE = """ +

You have a new booking at {{ venue.venue_name }} (Order {{ order.name }}).

+

Customer: {{ order.customer_name or "Guest" }} + {% if order.customer_phone %}· {{ order.customer_phone }}{% endif %} + {% if order.customer_email %}· {{ order.customer_email }}{% endif %}

+
    +{% for line in lines %} +
  • {{ line.resource_name }} — {{ line.booking_date }}, {{ line.start }}-{{ line.end }} + (₹{{ "{:,.0f}".format(line.amount or 0) }})
  • +{% endfor %} +
+

Total: ₹{{ "{:,.0f}".format(order.total_amount or 0) }}

+""" diff --git a/apna_slot/patches.txt b/apna_slot/patches.txt index 07b8ea6..4140ce7 100644 --- a/apna_slot/patches.txt +++ b/apna_slot/patches.txt @@ -1,6 +1,7 @@ [pre_model_sync] # Patches added in this section will be executed before doctypes are migrated # Read docs to understand patches: https://docs.frappe.io/framework/user/en/database-migrations +apna_slot.patches.create_venue_publisher_role [post_model_sync] # Patches added in this section will be executed after doctypes are migrated \ No newline at end of file diff --git a/apna_slot/patches/create_venue_publisher_role.py b/apna_slot/patches/create_venue_publisher_role.py new file mode 100644 index 0000000..637021d --- /dev/null +++ b/apna_slot/patches/create_venue_publisher_role.py @@ -0,0 +1,14 @@ +import frappe + + +def execute(): + # Runs pre-model-sync so the role exists before Venue permissions reference it. + if frappe.db.exists("Role", "Venue Publisher"): + return + frappe.get_doc( + { + "doctype": "Role", + "role_name": "Venue Publisher", + "desk_access": 0, + } + ).insert(ignore_permissions=True) diff --git a/apna_slot/permissions.py b/apna_slot/permissions.py new file mode 100644 index 0000000..fe2afd7 --- /dev/null +++ b/apna_slot/permissions.py @@ -0,0 +1,80 @@ +"""Row-level scoping: a publisher only ever sees their own venues and everything +hanging off them (resources, orders, bookings, reviews). + +Registered in ``hooks.py`` via ``permission_query_conditions`` (list/report reads) +and ``has_permission`` (single-doc reads/writes). System Managers are unscoped. +""" + +import frappe + + +def venue_query(user: str | None = None) -> str: + user = user or frappe.session.user + if is_admin(user): + return "" + return f"`tabVenue`.publisher = {frappe.db.escape(user)}" + + +def venue_resource_query(user: str | None = None) -> str: + return _venue_scoped_query("Venue Resource", user) + + +def booking_order_query(user: str | None = None) -> str: + return _venue_scoped_query("Booking Order", user) + + +def booking_query(user: str | None = None) -> str: + return _venue_scoped_query("Booking", user) + + +def venue_review_query(user: str | None = None) -> str: + return _venue_scoped_query("Venue Review", user) + + +def _venue_scoped_query(doctype: str, user: str | None) -> str: + """Restrict a DocType with a ``venue`` link to the caller's own venues.""" + user = user or frappe.session.user + if is_admin(user): + return "" + return f"`tab{doctype}`.venue in {_own_venues_subquery(user)}" + + +def _own_venues_subquery(user: str) -> str: + return f"(select name from `tabVenue` where publisher = {frappe.db.escape(user)})" + + +def has_venue_permission(doc, ptype=None, user=None): + user = user or frappe.session.user + if is_admin(user): + return True + # New doc: publisher is assigned to the session user on validate. + if not doc.get("publisher"): + return True + return doc.publisher == user + + +def has_venue_resource_permission(doc, ptype=None, user=None): + return _owns_venue(doc.get("venue"), user) + + +def has_booking_order_permission(doc, ptype=None, user=None): + return _owns_venue(doc.get("venue"), user) + + +def has_booking_permission(doc, ptype=None, user=None): + return _owns_venue(doc.get("venue"), user) + + +def has_venue_review_permission(doc, ptype=None, user=None): + return _owns_venue(doc.get("venue"), user) + + +def _owns_venue(venue, user=None): + user = user or frappe.session.user + if is_admin(user): + return True + return bool(venue) and frappe.db.get_value("Venue", venue, "publisher") == user + + +def is_admin(user: str) -> bool: + return user == "Administrator" or "System Manager" in frappe.get_roles(user) diff --git a/apna_slot/tasks.py b/apna_slot/tasks.py new file mode 100644 index 0000000..c4eaf36 --- /dev/null +++ b/apna_slot/tasks.py @@ -0,0 +1,26 @@ +"""Scheduled jobs. Registered in hooks.py `scheduler_events`.""" + +import frappe +from frappe.utils import now_datetime + + +def expire_holds(): + """Free slots whose 10-minute hold lapsed before payment (cron `* * * * *`). + + Each expired order and all its lines move to `Expired`; re-saving the lines + rewrites their slot_key to the inactive form, releasing every held slot. + """ + overdue = frappe.get_all( + "Booking Order", + filters={"status": "Held", "hold_expires_at": ["<", now_datetime()]}, + pluck="name", + ) + for name in overdue: + _expire(name) + + +def _expire(name): + order = frappe.get_doc("Booking Order", name) + order.sync_status("Expired") + if order.payment: + frappe.db.set_value("Payment Transaction", order.payment, "status", "Failed") diff --git a/apna_slot/tests/fixtures.py b/apna_slot/tests/fixtures.py index bd65f57..a389571 100644 --- a/apna_slot/tests/fixtures.py +++ b/apna_slot/tests/fixtures.py @@ -13,7 +13,14 @@ ] -def make_venue(base_price=500, open_time="06:00:00", close_time="22:00:00", duration=60, status="Published"): +def make_venue( + base_price=500, + open_time="06:00:00", + close_time="22:00:00", + duration=60, + status="Published", + publisher=None, +): # Unique slug per venue: IntegrationTestCase rolls back once per class, so # every setUp in a class must avoid the slug unique index. suffix = frappe.generate_hash(length=6) @@ -22,6 +29,8 @@ def make_venue(base_price=500, open_time="06:00:00", close_time="22:00:00", dura venue.slug = f"test-arena-{suffix}" venue.status = status venue.base_price = base_price + if publisher: + venue.publisher = publisher for day in WEEKDAYS: venue.append( "weekly_availability", @@ -46,9 +55,32 @@ def make_resource(venue, status="Active"): return resource.name -def customer(): - return {"name": "Test Customer", "phone": "9999999999"} +def customer(email=None): + return {"name": "Test Customer", "phone": "9999999999", "email": email} def line(resource, date, slot): return {"resource": resource, "date": date, "start": slot["start"]} + + +def make_confirmed_booking( + venue, resource, date, start="10:00:00", email=None, customer=None, status="Confirmed" +): + """A Confirmed order + one line, bypassing slot availability (test-only).""" + order = frappe.new_doc("Booking Order") + order.venue = venue + order.customer_name = "Test Customer" + order.customer_email = email + order.customer = customer + order.status = status + order.insert(ignore_permissions=True) + booking = frappe.new_doc("Booking") + booking.order = order.name + booking.venue = venue + booking.resource = resource + booking.booking_date = date + booking.start_time = start + booking.status = status + booking.insert(ignore_permissions=True) + order.recompute_total() + return {"order": order.name, "booking": booking.name} diff --git a/apna_slot/tests/test_booking_flow.py b/apna_slot/tests/test_booking_flow.py index ba620d2..1438a82 100644 --- a/apna_slot/tests/test_booking_flow.py +++ b/apna_slot/tests/test_booking_flow.py @@ -14,8 +14,10 @@ def setUp(self): self.slots = public.get_slots(self.resource, self.date) self.target = self.slots[0] - def _hold(self): - return public.hold_slots(self.venue, [line(self.resource, self.date, self.target)], customer()) + def _hold(self, *targets): + targets = targets or (self.target,) + lines = [line(self.resource, self.date, slot) for slot in targets] + return public.hold_slots(self.venue, lines, customer()) def test_hold_then_confirm(self): hold = self._hold() @@ -40,3 +42,21 @@ def test_second_hold_on_same_slot_is_rejected(self): second = self._hold() self.assertFalse(second["ok"]) self.assertEqual(second["reason"], "slots_taken") + + def test_multi_slot_happy_path(self): + hold = self._hold(self.slots[0], self.slots[1]) + self.assertTrue(hold["ok"]) + self.assertEqual(len(hold["lines"]), 2) + self.assertEqual(hold["total_amount"], 1000) + + public.confirm_payment(hold["payment"]) + self.assertEqual(frappe.db.get_value("Booking Order", hold["order"], "status"), "Confirmed") + statuses = frappe.get_all("Booking", {"order": hold["order"]}, pluck="status") + self.assertEqual(statuses, ["Confirmed", "Confirmed"]) + + def test_held_slot_blocks_second_hold_before_payment(self): + # A hold alone (unpaid) already locks the slot. + self.assertTrue(self._hold()["ok"]) + second = self._hold() + self.assertFalse(second["ok"]) + self.assertEqual(second["reason"], "slots_taken") diff --git a/apna_slot/tests/test_booking_lifecycle.py b/apna_slot/tests/test_booking_lifecycle.py new file mode 100644 index 0000000..909d34f --- /dev/null +++ b/apna_slot/tests/test_booking_lifecycle.py @@ -0,0 +1,74 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, add_to_date, getdate, now_datetime + +from apna_slot import tasks +from apna_slot.api import public +from apna_slot.tests.fixtures import customer, line, make_resource, make_venue + + +class TestBookingLifecycle(IntegrationTestCase): + def setUp(self): + self.venue = make_venue(base_price=500) + self.resource = make_resource(self.venue) + self.date = add_days(getdate(), 3).isoformat() + self.slots = public.get_slots(self.resource, self.date) + + def _lines(self, *slots): + return [line(self.resource, self.date, slot) for slot in slots] + + def _hold(self, *slots, email=None): + return public.hold_slots(self.venue, self._lines(*slots), customer(email=email)) + + def _is_available(self, slot): + refreshed = public.get_slots(self.resource, self.date) + return next(s for s in refreshed if s["start"] == slot["start"])["available"] + + def test_all_or_nothing_leaves_no_partial_order(self): + taken_slot, free_slot = self.slots[0], self.slots[1] + public.confirm_payment(self._hold(taken_slot)["payment"]) + orders_before = frappe.db.count("Booking Order") + + result = self._hold(free_slot, taken_slot) + self.assertFalse(result["ok"]) + self.assertEqual(result["reason"], "slots_taken") + self.assertEqual([s["start"] for s in result["taken"]], [taken_slot["start"]]) + # No order created for the failed attempt; the free slot stays available. + self.assertEqual(frappe.db.count("Booking Order"), orders_before) + self.assertTrue(self._is_available(free_slot)) + + def test_duplicate_slot_in_request_is_rejected(self): + with self.assertRaises(frappe.ValidationError): + self._hold(self.slots[0], self.slots[0]) + + def test_expire_holds_frees_every_slot(self): + hold = self._hold(self.slots[0], self.slots[1]) + self.assertFalse(self._is_available(self.slots[0])) + + frappe.db.set_value( + "Booking Order", hold["order"], "hold_expires_at", add_to_date(now_datetime(), minutes=-1) + ) + tasks.expire_holds() + + self.assertEqual(frappe.db.get_value("Booking Order", hold["order"], "status"), "Expired") + self.assertEqual(frappe.db.get_value("Payment Transaction", hold["payment"], "status"), "Failed") + self.assertTrue(self._is_available(self.slots[0])) + self.assertTrue(self._is_available(self.slots[1])) + + def test_confirm_after_expiry_is_rejected(self): + hold = self._hold(self.slots[0]) + frappe.db.set_value( + "Booking Order", hold["order"], "hold_expires_at", add_to_date(now_datetime(), minutes=-1) + ) + with self.assertRaises(frappe.ValidationError): + public.confirm_payment(hold["payment"]) + + def test_create_account_links_customer(self): + email = f"guest-{frappe.generate_hash(length=6)}@example.com" + hold = self._hold(self.slots[0], email=email) + public.confirm_payment(hold["payment"]) + + result = public.create_account_from_order(hold["order"], "s3cret-pass-123") + self.assertTrue(result["ok"]) + self.assertEqual(frappe.db.get_value("Booking Order", hold["order"], "customer"), email) + self.assertTrue(frappe.db.exists("User", email)) diff --git a/apna_slot/tests/test_dashboard.py b/apna_slot/tests/test_dashboard.py new file mode 100644 index 0000000..28cc3ce --- /dev/null +++ b/apna_slot/tests/test_dashboard.py @@ -0,0 +1,67 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import getdate + +from apna_slot.api import publisher +from apna_slot.tests.fixtures import make_confirmed_booking, make_resource, make_venue + +PUBLISHER_ROLE = "Venue Publisher" + + +def make_publisher_user(email): + if not frappe.db.exists("User", email): + user = frappe.new_doc("User") + user.email = email + user.first_name = "Publisher" + user.user_type = "Website User" + user.insert(ignore_permissions=True) + user = frappe.get_doc("User", email) + if PUBLISHER_ROLE not in [row.role for row in user.roles]: + user.append("roles", {"role": PUBLISHER_ROLE}) + user.save(ignore_permissions=True) + return email + + +class TestPublisherDashboard(IntegrationTestCase): + def setUp(self): + # IntegrationTestCase rolls back once per class, not per test, so every + # publisher must be unique per test to keep venue ownership isolated. + self.suffix = frappe.generate_hash(length=6) + self.today = getdate().isoformat() + self.alice = make_publisher_user(f"alice-{self.suffix}@example.com") + self.bob = make_publisher_user(f"bob-{self.suffix}@example.com") + self.addCleanup(frappe.set_user, "Administrator") + + self.venue_a = make_venue(publisher=self.alice) + self.resource_a = make_resource(self.venue_a) + make_confirmed_booking(self.venue_a, self.resource_a, self.today, start="10:00:00") + make_confirmed_booking(self.venue_a, self.resource_a, self.today, start="11:00:00") + + self.venue_b = make_venue(publisher=self.bob) + self.resource_b = make_resource(self.venue_b) + make_confirmed_booking(self.venue_b, self.resource_b, self.today, start="10:00:00") + + def test_dashboard_scoped_to_own_venues(self): + frappe.set_user(self.alice) + data = publisher.get_publisher_dashboard() + self.assertEqual(data["bookings_today"], 2) + self.assertEqual(data["revenue_month"], 1000) + self.assertEqual([str(row["venue"]) for row in data["by_venue"]], [str(self.venue_a)]) + + def test_other_publisher_sees_different_numbers(self): + frappe.set_user(self.bob) + data = publisher.get_publisher_dashboard() + self.assertEqual(data["bookings_today"], 1) + self.assertEqual(data["revenue_month"], 500) + + def test_publisher_with_no_venues_gets_zeroes(self): + frappe.set_user(make_publisher_user(f"carol-{self.suffix}@example.com")) + data = publisher.get_publisher_dashboard() + self.assertEqual(data["bookings_today"], 0) + self.assertEqual(data["upcoming"], []) + + def test_bookings_list_excludes_other_publishers(self): + frappe.set_user(self.alice) + names = frappe.get_list("Booking", pluck="name", limit_page_length=0) + venues = {str(frappe.db.get_value("Booking", name, "venue")) for name in names} + self.assertEqual(venues, {str(self.venue_a)}) diff --git a/apna_slot/tests/test_notifications.py b/apna_slot/tests/test_notifications.py new file mode 100644 index 0000000..7145905 --- /dev/null +++ b/apna_slot/tests/test_notifications.py @@ -0,0 +1,49 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, getdate + +from apna_slot import notifications +from apna_slot.api import public +from apna_slot.tests.fixtures import customer, line, make_resource, make_venue + +CUSTOMER_EMAIL = "guest.reviewer@example.com" + + +class TestConfirmationEmails(IntegrationTestCase): + def setUp(self): + self.venue = make_venue() + self.resource = make_resource(self.venue) + self.date = add_days(getdate(), 2).isoformat() + self.slots = public.get_slots(self.resource, self.date) + + def _hold(self, *targets): + targets = targets or (self.slots[0],) + lines = [line(self.resource, self.date, slot) for slot in targets] + return public.hold_slots(self.venue, lines, customer(email=CUSTOMER_EMAIL)) + + def test_confirm_triggers_one_send(self): + with patch("apna_slot.notifications.send_confirmation_emails") as spy: + hold = self._hold(self.slots[0], self.slots[1]) + spy.assert_not_called() # a bare hold never notifies + public.confirm_payment(hold["payment"]) + spy.assert_called_once() + self.assertEqual(spy.call_args.kwargs["order"], hold["order"]) + + def test_no_send_when_hold_expires(self): + with patch("apna_slot.notifications.send_confirmation_emails") as spy: + hold = self._hold() + frappe.get_doc("Booking Order", hold["order"]).sync_status("Expired") + spy.assert_not_called() + + def test_one_email_per_recipient_lists_every_slot(self): + hold = self._hold(self.slots[0], self.slots[1]) + order_doc = frappe.get_doc("Booking Order", hold["order"]) + emails = notifications.build_confirmation_emails(order_doc) + + self.assertEqual(len(emails), 2) # one customer + one publisher + recipients = {next(iter(email["recipients"])) for email in emails} + self.assertIn(CUSTOMER_EMAIL, recipients) + for email in emails: + self.assertEqual(email["message"].count("
  • "), 2) diff --git a/apna_slot/tests/test_permissions.py b/apna_slot/tests/test_permissions.py new file mode 100644 index 0000000..5dad6ef --- /dev/null +++ b/apna_slot/tests/test_permissions.py @@ -0,0 +1,54 @@ +import frappe +from frappe.tests import IntegrationTestCase + + +class TestPublisherIsolation(IntegrationTestCase): + def setUp(self): + self.alice = self.make_publisher("alice.publisher@example.com") + self.bob = self.make_publisher("bob.publisher@example.com") + self.alice_venue = self.make_venue(self.alice) + self.bob_venue = self.make_venue(self.bob) + + def tearDown(self): + frappe.set_user("Administrator") + + def make_publisher(self, email): + if not frappe.db.exists("User", email): + user = frappe.new_doc("User") + user.email = email + user.first_name = email.split("@")[0] + user.append("roles", {"role": "Venue Publisher"}) + user.insert(ignore_permissions=True) + return email + + def make_venue(self, publisher): + venue = frappe.new_doc("Venue") + venue.venue_name = f"Arena {frappe.generate_hash(length=6)}" + venue.publisher = publisher + venue.base_price = 300 + venue.insert(ignore_permissions=True) + return venue.name + + def test_list_scoped_to_own_venues(self): + frappe.set_user(self.alice) + names = [row.name for row in frappe.get_list("Venue", limit_page_length=0)] + self.assertIn(self.alice_venue, names) + self.assertNotIn(self.bob_venue, names) + + def test_cannot_read_other_publisher_venue(self): + frappe.set_user(self.alice) + self.assertTrue(frappe.has_permission("Venue", doc=self.alice_venue, ptype="read")) + self.assertFalse(frappe.has_permission("Venue", doc=self.bob_venue, ptype="read")) + + def test_resource_list_scoped_through_venue(self): + resource = frappe.get_doc( + { + "doctype": "Venue Resource", + "resource_name": "Court", + "venue": self.bob_venue, + "status": "Active", + } + ).insert(ignore_permissions=True) + frappe.set_user(self.alice) + names = [row.name for row in frappe.get_list("Venue Resource", limit_page_length=0)] + self.assertNotIn(resource.name, names) diff --git a/apna_slot/tests/test_pricing.py b/apna_slot/tests/test_pricing.py new file mode 100644 index 0000000..d550c8b --- /dev/null +++ b/apna_slot/tests/test_pricing.py @@ -0,0 +1,42 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from apna_slot.engine.pricing import compute_price +from apna_slot.tests.fixtures import make_venue + + +class TestPricing(IntegrationTestCase): + def setUp(self): + self.venue = frappe.get_doc("Venue", make_venue(base_price=500)) + self._add_rule("Weekend", "Weekend", "00:00:00", "23:59:59", 200) + self._add_rule("Peak", "All", "18:00:00", "22:00:00", 300) + self.venue.save(ignore_permissions=True) + + def _add_rule(self, label, day_type, from_time, to_time, amount): + self.venue.append( + "pricing_rules", + { + "label": label, + "day_type": day_type, + "from_time": from_time, + "to_time": to_time, + "surcharge_amount": amount, + }, + ) + + def test_base_only_off_peak_weekday(self): + self.assertEqual(compute_price(self.venue, "Monday", "09:00:00"), 500) + + def test_weekend_evening_stacks_both_surcharges(self): + # base 500 + weekend 200 + peak 300 = 1000 + self.assertEqual(compute_price(self.venue, "Saturday", "19:00:00"), 1000) + + def test_weekday_peak_only(self): + self.assertEqual(compute_price(self.venue, "Wednesday", "19:00:00"), 800) + + def test_weekend_off_peak(self): + self.assertEqual(compute_price(self.venue, "Sunday", "10:00:00"), 700) + + def test_band_end_is_exclusive(self): + # Peak band is 18:00-22:00; 22:00 start does not match. + self.assertEqual(compute_price(self.venue, "Monday", "22:00:00"), 500) diff --git a/apna_slot/tests/test_public_api.py b/apna_slot/tests/test_public_api.py new file mode 100644 index 0000000..494f67b --- /dev/null +++ b/apna_slot/tests/test_public_api.py @@ -0,0 +1,73 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, getdate + +from apna_slot.api.public import get_public_venue, get_slots, list_cities, search_venues +from apna_slot.tests.fixtures import make_resource, make_venue + + +class TestPublicApi(IntegrationTestCase): + def setUp(self): + self.venue = make_venue(status="Published") + self.resource = make_resource(self.venue) + self.date = add_days(getdate(), 2).isoformat() + + def test_get_public_venue_returns_safe_payload(self): + data = get_public_venue(frappe.db.get_value("Venue", self.venue, "slug")) + self.assertNotIn("publisher", data["venue"]) + self.assertEqual(data["venue"]["avg_rating"], 0) + self.assertEqual(len(data["resources"]), 1) + + def test_get_public_venue_rejects_unpublished(self): + slug = frappe.db.get_value("Venue", self.venue, "slug") + frappe.db.set_value("Venue", self.venue, "status", "Unpublished") + with self.assertRaises(frappe.DoesNotExistError): + get_public_venue(slug) + + def test_get_slots_rejects_resource_of_unpublished_venue(self): + frappe.db.set_value("Venue", self.venue, "status", "Draft") + with self.assertRaises(frappe.DoesNotExistError): + get_slots(self.resource, self.date) + + def test_get_slots_rejects_inactive_resource(self): + inactive = make_resource(self.venue, status="Inactive") + with self.assertRaises(frappe.DoesNotExistError): + get_slots(inactive, self.date) + + def test_get_slots_returns_priced_slots(self): + slots = get_slots(self.resource, self.date) + self.assertTrue(slots) + self.assertIn("price", slots[0]) + self.assertIn("available", slots[0]) + + def test_search_returns_published_hides_draft_and_publisher(self): + tag = frappe.generate_hash(length=6) + frappe.db.set_value("Venue", self.venue, "city", tag) + draft = make_venue(status="Draft") + frappe.db.set_value("Venue", draft, "city", tag) + results = search_venues(city=tag) + slugs = [row["slug"] for row in results] + self.assertIn(frappe.db.get_value("Venue", self.venue, "slug"), slugs) + self.assertNotIn(frappe.db.get_value("Venue", draft, "slug"), slugs) + self.assertNotIn("publisher", results[0]) + self.assertIn("avg_rating", results[0]) + + def test_search_filters_by_city(self): + here, elsewhere = frappe.generate_hash(length=6), frappe.generate_hash(length=6) + frappe.db.set_value("Venue", self.venue, "city", here) + other = make_venue(status="Published") + frappe.db.set_value("Venue", other, "city", elsewhere) + slugs = [row["slug"] for row in search_venues(city=here)] + self.assertIn(frappe.db.get_value("Venue", self.venue, "slug"), slugs) + self.assertNotIn(frappe.db.get_value("Venue", other, "slug"), slugs) + + def test_search_text_matches_city(self): + tag = frappe.generate_hash(length=6) + frappe.db.set_value("Venue", self.venue, "city", tag) + slugs = [row["slug"] for row in search_venues(q=tag)] + self.assertIn(frappe.db.get_value("Venue", self.venue, "slug"), slugs) + + def test_list_cities_includes_published_city(self): + tag = frappe.generate_hash(length=6) + frappe.db.set_value("Venue", self.venue, "city", tag) + self.assertIn(tag, list_cities()) diff --git a/apna_slot/tests/test_reviews.py b/apna_slot/tests/test_reviews.py new file mode 100644 index 0000000..8616e70 --- /dev/null +++ b/apna_slot/tests/test_reviews.py @@ -0,0 +1,53 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, getdate + +from apna_slot.api import public +from apna_slot.tests.fixtures import make_confirmed_booking, make_resource, make_venue + + +class TestReviews(IntegrationTestCase): + def setUp(self): + self.venue = make_venue() + self.slug = frappe.db.get_value("Venue", self.venue, "slug") + self.resource = make_resource(self.venue) + self.date = add_days(getdate(), 1).isoformat() + + def _confirmed(self, start="10:00:00"): + return make_confirmed_booking( + self.venue, self.resource, self.date, start=start, customer=frappe.session.user + )["booking"] + + def test_review_a_confirmed_booking(self): + booking = self._confirmed() + result = public.create_review(booking, 4, "Great turf") + self.assertTrue(result["ok"]) + review = frappe.get_doc("Venue Review", result["review"]) + self.assertEqual(str(review.venue), str(self.venue)) + self.assertEqual(review.rating, 0.8) + + def test_second_review_on_same_booking_rejected(self): + booking = self._confirmed() + public.create_review(booking, 5, "Nice") + with self.assertRaises(frappe.ValidationError): + public.create_review(booking, 3, "Changed my mind") + + def test_non_confirmed_booking_cannot_be_reviewed(self): + held = make_confirmed_booking( + self.venue, self.resource, self.date, customer=frappe.session.user, status="Held" + )["booking"] + with self.assertRaises(frappe.ValidationError): + public.create_review(held, 5, "Too early") + + def test_invalid_star_value_rejected(self): + booking = self._confirmed() + with self.assertRaises(frappe.ValidationError): + public.create_review(booking, 6, "Off the scale") + + def test_venue_page_reflects_average_and_count(self): + public.create_review(self._confirmed("10:00:00"), 5, "Excellent") + public.create_review(self._confirmed("11:00:00"), 4, "Good") + payload = public.get_public_venue(self.slug)["venue"] + self.assertEqual(payload["review_count"], 2) + self.assertEqual(payload["avg_rating"], 4.5) + self.assertEqual(len(payload["reviews"]), 2) diff --git a/apna_slot/tests/test_slots.py b/apna_slot/tests/test_slots.py index 6da4ad9..0b93680 100644 --- a/apna_slot/tests/test_slots.py +++ b/apna_slot/tests/test_slots.py @@ -29,3 +29,35 @@ def test_closed_weekday_has_no_slots(self): row.is_open = 0 venue.save(ignore_permissions=True) self.assertEqual(get_slots_for(self.resource, self.date), []) + + def test_closed_exception_overrides_open_day(self): + self._add_exception("Closed") + self.assertEqual(get_slots_for(self.resource, self.date), []) + + def test_special_hours_exception_overrides_weekly(self): + self._add_exception("Special Hours", open_time="10:00:00", close_time="11:00:00") + slots = get_slots_for(self.resource, self.date) + self.assertEqual(len(slots), 1) + self.assertEqual(slots[0]["start"], "10:00:00") + self.assertEqual(slots[0]["end"], "11:00:00") + + def test_midnight_crossing_generates_next_day_slots(self): + venue = make_venue(open_time="22:00:00", close_time="01:00:00", duration=60) + resource = make_resource(venue) + slots = get_slots_for(resource, self.date) + # 22:00, 23:00, 00:00 (wrapped) → 3 slots; last ends at 01:00. + self.assertEqual([s["start"] for s in slots], ["22:00:00", "23:00:00", "00:00:00"]) + self.assertEqual(slots[-1]["end"], "01:00:00") + + def _add_exception(self, exception_type, open_time=None, close_time=None): + venue = frappe.get_doc("Venue", self.venue) + venue.append( + "availability_exceptions", + { + "date": self.date, + "exception_type": exception_type, + "open_time": open_time, + "close_time": close_time, + }, + ) + venue.save(ignore_permissions=True) diff --git a/apna_slot/tests/test_venue.py b/apna_slot/tests/test_venue.py new file mode 100644 index 0000000..e3e4b12 --- /dev/null +++ b/apna_slot/tests/test_venue.py @@ -0,0 +1,74 @@ +import re + +import frappe +from frappe.tests import IntegrationTestCase + + +class TestVenue(IntegrationTestCase): + def make(self, name=None): + name = name or f"Venue {frappe.generate_hash(length=6)}" + venue = frappe.new_doc("Venue") + venue.venue_name = name + venue.base_price = 500 + venue.insert(ignore_permissions=True) + return venue + + def add_active_resource(self, venue): + frappe.get_doc( + { + "doctype": "Venue Resource", + "resource_name": "Court A", + "venue": venue.name, + "status": "Active", + } + ).insert(ignore_permissions=True) + + def test_slug_autogenerated_from_name(self): + name = f"Sunny Court {frappe.generate_hash(length=5)}" + venue = self.make(name) + expected = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + self.assertEqual(venue.slug, expected) + + def test_duplicate_names_get_distinct_slugs(self): + name = f"Twin Arena {frappe.generate_hash(length=5)}" + first = self.make(name) + second = self.make(name) + self.assertNotEqual(first.slug, second.slug) + self.assertTrue(second.slug.endswith("-2")) + + def test_publisher_defaults_to_session_user(self): + venue = self.make() + self.assertEqual(venue.publisher, frappe.session.user) + + def test_seven_weekdays_seeded(self): + venue = self.make() + self.assertEqual(len(venue.weekly_availability), 7) + + def test_publish_blocked_without_active_resource(self): + venue = self.make() + with self.assertRaises(frappe.ValidationError): + venue.publish() + self.assertEqual(venue.status, "Draft") + + def test_publish_blocked_when_week_all_closed(self): + venue = self.make() + self.add_active_resource(venue) + for row in venue.weekly_availability: + row.is_open = 0 + venue.save(ignore_permissions=True) + with self.assertRaises(frappe.ValidationError): + venue.publish() + + def test_publish_sets_status_and_timestamp(self): + venue = self.make() + self.add_active_resource(venue) + venue.publish() + self.assertEqual(venue.status, "Published") + self.assertTrue(venue.published_on) + + def test_unpublish_reverts_status(self): + venue = self.make() + self.add_active_resource(venue) + venue.publish() + venue.unpublish() + self.assertEqual(venue.status, "Unpublished") diff --git a/docs/images/01-marketplace.png b/docs/images/01-marketplace.png new file mode 100644 index 0000000..7137f97 Binary files /dev/null and b/docs/images/01-marketplace.png differ diff --git a/docs/images/02-venue-page.png b/docs/images/02-venue-page.png new file mode 100644 index 0000000..630f1d2 Binary files /dev/null and b/docs/images/02-venue-page.png differ diff --git a/docs/images/03-selection-cart.png b/docs/images/03-selection-cart.png new file mode 100644 index 0000000..2ae60a4 Binary files /dev/null and b/docs/images/03-selection-cart.png differ diff --git a/docs/images/04-checkout.png b/docs/images/04-checkout.png new file mode 100644 index 0000000..fedb01a Binary files /dev/null and b/docs/images/04-checkout.png differ diff --git a/docs/images/05-mock-gateway.png b/docs/images/05-mock-gateway.png new file mode 100644 index 0000000..8c9752c Binary files /dev/null and b/docs/images/05-mock-gateway.png differ diff --git a/docs/images/06-confirmation.png b/docs/images/06-confirmation.png new file mode 100644 index 0000000..3529a4c Binary files /dev/null and b/docs/images/06-confirmation.png differ diff --git a/docs/images/07-my-venues.png b/docs/images/07-my-venues.png new file mode 100644 index 0000000..2257a72 Binary files /dev/null and b/docs/images/07-my-venues.png differ diff --git a/docs/images/08-dashboard.png b/docs/images/08-dashboard.png new file mode 100644 index 0000000..d1cb007 Binary files /dev/null and b/docs/images/08-dashboard.png differ diff --git a/docs/images/09-bookings.png b/docs/images/09-bookings.png new file mode 100644 index 0000000..8940b01 Binary files /dev/null and b/docs/images/09-bookings.png differ diff --git a/docs/images/10-venue-editor.png b/docs/images/10-venue-editor.png new file mode 100644 index 0000000..891bde0 Binary files /dev/null and b/docs/images/10-venue-editor.png differ diff --git a/docs/images/11-availability-tab.png b/docs/images/11-availability-tab.png new file mode 100644 index 0000000..cfa1625 Binary files /dev/null and b/docs/images/11-availability-tab.png differ diff --git a/e2e/helpers/apnaslot.ts b/e2e/helpers/apnaslot.ts new file mode 100644 index 0000000..bf82561 --- /dev/null +++ b/e2e/helpers/apnaslot.ts @@ -0,0 +1,174 @@ +import { APIRequestContext } from "@playwright/test"; +import { createDoc, deleteDoc, getDoc, getList } from "./frappe"; + +const WEEKDAYS = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +]; + +/** + * Venue document interface. + */ +export interface Venue { + name: string; + venue_name: string; + slug: string; + status: string; + base_price?: number; + publisher?: string; + creation?: string; + modified?: string; +} + +/** + * Venue Resource document interface. + */ +export interface VenueResource { + name: string; + resource_name: string; + venue: string; + status: string; +} + +/** + * Booking Order document interface. + */ +export interface BookingOrder { + name: string; + venue: string; + customer_name?: string; + customer_email?: string; + status: string; + total_amount?: number; +} + +/** + * Generate a unique venue name for tests. + */ +export function generateVenueName(prefix = "E2E Test Arena"): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + return `${prefix} ${timestamp}-${random}`; +} + +/** + * Weekly availability rows for a venue (all days open). + */ +function weeklyAvailability( + openTime = "06:00:00", + closeTime = "22:00:00", + duration = 60, +): Record[] { + return WEEKDAYS.map((weekday) => ({ + weekday, + is_open: 1, + open_time: openTime, + close_time: closeTime, + slot_duration_minutes: duration, + })); +} + +/** + * Create a test Venue (with full weekly availability) via API. + */ +export async function createTestVenue( + request: APIRequestContext, + options: { + venue_name?: string; + slug?: string; + base_price?: number; + status?: string; + publisher?: string; + } = {}, +): Promise { + const venue_name = options.venue_name || generateVenueName(); + const slug = + options.slug || + venue_name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + + return createDoc(request, "Venue", { + venue_name, + slug, + status: options.status ?? "Published", + base_price: options.base_price ?? 500, + publisher: options.publisher, + weekly_availability: weeklyAvailability(), + }); +} + +/** + * Create an Active Venue Resource under a venue via API. + */ +export async function createTestResource( + request: APIRequestContext, + options: { venue: string; resource_name?: string; status?: string }, +): Promise { + return createDoc(request, "Venue Resource", { + resource_name: options.resource_name || "Test Court", + venue: options.venue, + status: options.status ?? "Active", + }); +} + +/** + * Get a Venue by name via API. + */ +export async function getVenue( + request: APIRequestContext, + name: string, +): Promise { + return getDoc(request, "Venue", name); +} + +/** + * Cleanup test venues matching a name pattern. + * Deletes linked bookings and resources first to avoid LinkExistsError. + */ +export async function cleanupTestVenues( + request: APIRequestContext, + namePattern = "E2E Test Arena", +): Promise { + const venues = await getList(request, "Venue", { + fields: ["name"], + filters: { venue_name: ["like", `${namePattern}%`] }, + limit: 100, + }); + + for (const venue of venues) { + await deleteLinked(request, "Booking", { venue: venue.name }); + await deleteLinked(request, "Booking Order", { venue: venue.name }); + await deleteLinked(request, "Venue Resource", { venue: venue.name }); + try { + await deleteDoc(request, "Venue", venue.name); + } catch (error) { + console.warn(`Failed to delete venue ${venue.name}:`, error); + } + } +} + +/** + * Delete every document of a doctype matching filters (best-effort). + */ +async function deleteLinked( + request: APIRequestContext, + doctype: string, + filters: Record, +): Promise { + const rows = await getList<{ name: string }>(request, doctype, { + fields: ["name"], + filters, + limit: 500, + }); + for (const row of rows) { + try { + await deleteDoc(request, doctype, row.name); + } catch (error) { + console.warn(`Failed to delete ${doctype} ${row.name}:`, error); + } + } +} diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..64c6758 --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,63 @@ +import { APIRequestContext, Page } from "@playwright/test"; + +/** + * Login via Frappe API (faster than UI login). + * Sets cookies on the request context for subsequent API calls. + */ +export async function loginViaAPI( + request: APIRequestContext, + email = "Administrator", + password = "admin", +): Promise { + const response = await request.post("/api/method/login", { + form: { + usr: email, + pwd: password, + }, + }); + + if (!response.ok()) { + throw new Error(`Login failed: ${response.status()} ${await response.text()}`); + } +} + +/** + * Login via UI (for testing the login flow itself). + */ +export async function loginViaUI( + page: Page, + email = "Administrator", + password = "admin", +): Promise { + await page.goto("/login"); + await page.waitForLoadState("networkidle"); + + await page.fill('input[data-fieldname="email"]', email); + await page.fill('input[data-fieldname="password"]', password); + await page.click('button[type="submit"]'); + + await page.waitForURL(/\/(app|desk)/, { timeout: 30000 }); +} + +/** + * Logout the current user. + */ +export async function logout(page: Page): Promise { + await page.goto("/api/method/logout"); + await page.waitForLoadState("networkidle"); +} + +/** + * Check if user is logged in by verifying session. + */ +export async function isLoggedIn(request: APIRequestContext): Promise { + try { + const response = await request.get("/api/method/frappe.auth.get_logged_user"); + if (!response.ok()) return false; + + const data = await response.json(); + return data.message && data.message !== "Guest"; + } catch { + return false; + } +} diff --git a/e2e/helpers/frappe.ts b/e2e/helpers/frappe.ts new file mode 100644 index 0000000..72f4dc6 --- /dev/null +++ b/e2e/helpers/frappe.ts @@ -0,0 +1,258 @@ +import * as fs from "fs"; +import { APIRequestContext } from "@playwright/test"; + +/** + * Frappe API response wrapper. + */ +export interface FrappeResponse { + message?: T; + exc?: string; + exc_type?: string; + _server_messages?: string; +} + +// Path to CSRF token file saved by auth.setup.ts +const CSRF_FILE = "e2e/.auth/csrf.json"; +// Path to auth storage state file saved by auth.setup.ts +const AUTH_FILE = "e2e/.auth/user.json"; + +// Node.js can't resolve .localhost TLDs, so API calls go via 127.0.0.1 + Host header. +const SITE_HOST = process.env.SITE_HOST || "apnaslot.localhost:8000"; +export const API_BASE = process.env.API_BASE || "http://127.0.0.1:8000"; + +// Cache for CSRF token (read from file once) +let csrfTokenCache: string | null = null; +// Cache for auth cookies (read from file once) +let cookieCache: string | null = null; + +/** + * Get CSRF token from the file saved during auth setup. + */ +function getCsrfToken(): string { + if (csrfTokenCache !== null) { + return csrfTokenCache; + } + + try { + if (fs.existsSync(CSRF_FILE)) { + const data = JSON.parse(fs.readFileSync(CSRF_FILE, "utf-8")); + csrfTokenCache = data.csrf_token || ""; + return csrfTokenCache; + } + } catch (error) { + console.warn("Failed to read CSRF token file:", error); + } + + csrfTokenCache = ""; + return ""; +} + +/** + * Get auth cookies from the storage state file saved during auth setup. + * Needed because the request fixture sends to 127.0.0.1 but cookies are + * scoped to the site domain, so they won't be sent automatically. + */ +function getAuthCookies(): string { + if (cookieCache !== null) { + return cookieCache; + } + + try { + if (fs.existsSync(AUTH_FILE)) { + const data = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")); + const cookies = data.cookies as Array<{ name: string; value: string }>; + if (cookies?.length) { + cookieCache = cookies.map((c) => `${c.name}=${c.value}`).join("; "); + return cookieCache; + } + } + } catch (error) { + console.warn("Failed to read auth cookies file:", error); + } + + cookieCache = ""; + return ""; +} + +/** + * Build common headers for API requests (CSRF + Host + auth cookies). + */ +function apiHeaders(extra: Record = {}): Record { + const csrfToken = getCsrfToken(); + const cookies = getAuthCookies(); + return { + Host: SITE_HOST, + ...(csrfToken ? { "X-Frappe-CSRF-Token": csrfToken } : {}), + ...(cookies ? { Cookie: cookies } : {}), + ...extra, + }; +} + +/** + * Create a new document via Frappe REST API. + */ +export async function createDoc>( + request: APIRequestContext, + doctype: string, + doc: Record, +): Promise { + const response = await request.post(`${API_BASE}/api/resource/${doctype}`, { + data: doc, + headers: apiHeaders({ "Content-Type": "application/json" }), + }); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to create ${doctype}: ${error}`); + } + + const result = await response.json(); + return result.data as T; +} + +/** + * Get a document by name via Frappe REST API. + */ +export async function getDoc>( + request: APIRequestContext, + doctype: string, + name: string, +): Promise { + const response = await request.get( + `${API_BASE}/api/resource/${doctype}/${encodeURIComponent(name)}`, + { headers: apiHeaders() }, + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to get ${doctype}/${name}: ${error}`); + } + + const result = await response.json(); + return result.data as T; +} + +/** + * Update a document via Frappe REST API. + */ +export async function updateDoc>( + request: APIRequestContext, + doctype: string, + name: string, + updates: Record, +): Promise { + const response = await request.put( + `${API_BASE}/api/resource/${doctype}/${encodeURIComponent(name)}`, + { + data: updates, + headers: apiHeaders({ "Content-Type": "application/json" }), + }, + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to update ${doctype}/${name}: ${error}`); + } + + const result = await response.json(); + return result.data as T; +} + +/** + * Delete a document via Frappe REST API. + */ +export async function deleteDoc( + request: APIRequestContext, + doctype: string, + name: string, +): Promise { + const response = await request.delete( + `${API_BASE}/api/resource/${doctype}/${encodeURIComponent(name)}`, + { headers: apiHeaders() }, + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to delete ${doctype}/${name}: ${error}`); + } +} + +/** + * Call a Frappe whitelisted method. + */ +export async function callMethod( + request: APIRequestContext, + method: string, + args: Record = {}, +): Promise { + const response = await request.post(`${API_BASE}/api/method/${method}`, { + data: args, + headers: apiHeaders({ "Content-Type": "application/json" }), + }); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to call ${method}: ${error}`); + } + + const result: FrappeResponse = await response.json(); + return result.message as T; +} + +/** + * Get a list of documents via Frappe REST API. + */ +export async function getList>( + request: APIRequestContext, + doctype: string, + options: { + fields?: string[]; + filters?: Record; + limit?: number; + orderBy?: string; + } = {}, +): Promise { + const params = new URLSearchParams(); + + if (options.fields) { + params.set("fields", JSON.stringify(options.fields)); + } + if (options.filters) { + params.set("filters", JSON.stringify(options.filters)); + } + if (options.limit) { + params.set("limit_page_length", options.limit.toString()); + } + if (options.orderBy) { + params.set("order_by", options.orderBy); + } + + const response = await request.get( + `${API_BASE}/api/resource/${doctype}?${params.toString()}`, + { headers: apiHeaders() }, + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to get list of ${doctype}: ${error}`); + } + + const result = await response.json(); + return result.data as T[]; +} + +/** + * Check if a document exists. + */ +export async function docExists( + request: APIRequestContext, + doctype: string, + name: string, +): Promise { + try { + await getDoc(request, doctype, name); + return true; + } catch { + return false; + } +} diff --git a/e2e/helpers/index.ts b/e2e/helpers/index.ts new file mode 100644 index 0000000..d874898 --- /dev/null +++ b/e2e/helpers/index.ts @@ -0,0 +1,3 @@ +export * from "./auth"; +export * from "./frappe"; +export * from "./apnaslot"; diff --git a/e2e/pages/index.ts b/e2e/pages/index.ts new file mode 100644 index 0000000..ac582ec --- /dev/null +++ b/e2e/pages/index.ts @@ -0,0 +1 @@ +// Page objects - add as needed diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts new file mode 100644 index 0000000..e2660bc --- /dev/null +++ b/e2e/tests/auth.setup.ts @@ -0,0 +1,74 @@ +import * as fs from "fs"; +import * as path from "path"; +import { expect, test as setup } from "@playwright/test"; + +const authFile = "e2e/.auth/user.json"; +const csrfFile = "e2e/.auth/csrf.json"; + +/** + * Authentication setup - runs once before all tests. + * Uses browser fetch() so cookies are saved for the correct domain + * (Chromium resolves apnaslot.localhost via --host-resolver-rules). + */ +setup("authenticate", async ({ page }) => { + const authDir = path.dirname(authFile); + if (!fs.existsSync(authDir)) { + fs.mkdirSync(authDir, { recursive: true }); + } + + const usr = process.env.FRAPPE_USER || "Administrator"; + const pwd = process.env.FRAPPE_PASSWORD || "admin"; + + // Navigate to login page first to establish the domain context + await page.goto("/login"); + await page.waitForLoadState("domcontentloaded"); + + // Login via browser fetch (keeps cookies on the correct domain) + const loginResult = await page.evaluate( + async ({ usr, pwd }) => { + const resp = await fetch("/api/method/login", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `usr=${encodeURIComponent(usr)}&pwd=${encodeURIComponent(pwd)}`, + }); + return { ok: resp.ok, status: resp.status }; + }, + { usr, pwd }, + ); + expect(loginResult.ok).toBeTruthy(); + + // Verify login succeeded + const loggedUser = await page.evaluate(async () => { + const resp = await fetch("/api/method/frappe.auth.get_logged_user"); + const data = await resp.json(); + return data.message as string; + }); + expect(loggedUser).toBeTruthy(); + expect(loggedUser).not.toBe("Guest"); + console.log(`Authenticated as: ${loggedUser}`); + + // Get CSRF token by navigating to /app (Frappe desk sets window.frappe.csrf_token) + await page.goto("/app", { waitUntil: "domcontentloaded" }); + // Wait a bit for Frappe to initialize + await page.waitForFunction( + () => + (window as unknown as { frappe?: { csrf_token?: string } }).frappe + ?.csrf_token !== undefined, + { timeout: 15000 }, + ).catch(() => { + // CSRF token may not be available in all setups - continue without it + console.warn("CSRF token not found, continuing without it"); + }); + + const csrfToken = await page.evaluate(() => { + return (window as unknown as { frappe?: { csrf_token?: string } }).frappe + ?.csrf_token; + }); + + if (csrfToken) { + fs.writeFileSync(csrfFile, JSON.stringify({ csrf_token: csrfToken })); + console.log("CSRF token saved"); + } + + await page.context().storageState({ path: authFile }); +}); diff --git a/e2e/tests/public-venue.spec.ts b/e2e/tests/public-venue.spec.ts new file mode 100644 index 0000000..dfa6f93 --- /dev/null +++ b/e2e/tests/public-venue.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from "@playwright/test"; + +// Stable slug seeded by apna_slot.e2e_seed.setup_e2e_data. +const SEEDED_SLUG = "e2e-arena"; +const SEEDED_VENUE_NAME = "E2E Arena"; + +test.describe("Public venue page", () => { + test("renders the seeded venue and its slot grid", async ({ page }) => { + await page.goto(`/v/${SEEDED_SLUG}`); + await page.waitForLoadState("domcontentloaded"); + + // Venue name from the seeded Published venue + await expect( + page.getByText(SEEDED_VENUE_NAME).first(), + ).toBeVisible({ timeout: 15000 }); + + // The slot grid renders time slots for the active resource + await expect(page.getByText(/\d{1,2}:\d{2}/).first()).toBeVisible({ + timeout: 15000, + }); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..77bf0e7 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@helpers/*": ["helpers/*"], + "@pages/*": ["pages/*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2e7af2b..7b75c83 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,6 @@ export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1856c0d..e5a01c3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,10 @@ diff --git a/frontend/src/components/StarRating.vue b/frontend/src/components/StarRating.vue new file mode 100644 index 0000000..46f017a --- /dev/null +++ b/frontend/src/components/StarRating.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/format.js b/frontend/src/format.js new file mode 100644 index 0000000..9596e38 --- /dev/null +++ b/frontend/src/format.js @@ -0,0 +1,18 @@ +export function money(amount) { + return `₹${Number(amount || 0).toLocaleString("en-IN")}`; +} + +export function clockTime(value) { + return String(value).slice(0, 5); +} + +export function todayISO() { + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${now.getFullYear()}-${month}-${day}`; +} + +export function slotKey(resource, date, start) { + return `${resource}::${date}::${start}`; +} diff --git a/frontend/src/main.js b/frontend/src/main.js index f15a4c6..44a49da 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,12 +1,12 @@ -import { createApp } from 'vue' -import { FrappeUI, setConfig, frappeRequest } from 'frappe-ui' -import { router } from './router' -import './style.css' -import App from './App.vue' +import { createApp } from "vue"; +import { FrappeUI, setConfig, frappeRequest } from "frappe-ui"; +import { router } from "./router"; +import "./style.css"; +import App from "./App.vue"; -setConfig('resourceFetcher', frappeRequest) +setConfig("resourceFetcher", frappeRequest); -const app = createApp(App) -app.use(router) -app.use(FrappeUI) -app.mount('#app') +const app = createApp(App); +app.use(router); +app.use(FrappeUI); +app.mount("#app"); diff --git a/frontend/src/pages/Bookings.vue b/frontend/src/pages/Bookings.vue new file mode 100644 index 0000000..e4a5ef9 --- /dev/null +++ b/frontend/src/pages/Bookings.vue @@ -0,0 +1,170 @@ + + + diff --git a/frontend/src/pages/Browse.vue b/frontend/src/pages/Browse.vue new file mode 100644 index 0000000..aa537fc --- /dev/null +++ b/frontend/src/pages/Browse.vue @@ -0,0 +1,102 @@ + + + diff --git a/frontend/src/pages/Checkout.vue b/frontend/src/pages/Checkout.vue new file mode 100644 index 0000000..255a1a8 --- /dev/null +++ b/frontend/src/pages/Checkout.vue @@ -0,0 +1,173 @@ + + + diff --git a/frontend/src/pages/Confirmation.vue b/frontend/src/pages/Confirmation.vue new file mode 100644 index 0000000..bb5fd21 --- /dev/null +++ b/frontend/src/pages/Confirmation.vue @@ -0,0 +1,143 @@ + + + diff --git a/frontend/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue new file mode 100644 index 0000000..5152c7e --- /dev/null +++ b/frontend/src/pages/Dashboard.vue @@ -0,0 +1,101 @@ + + + diff --git a/frontend/src/pages/ManageShell.vue b/frontend/src/pages/ManageShell.vue new file mode 100644 index 0000000..46972d7 --- /dev/null +++ b/frontend/src/pages/ManageShell.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/pages/MyVenues.vue b/frontend/src/pages/MyVenues.vue new file mode 100644 index 0000000..1e326f5 --- /dev/null +++ b/frontend/src/pages/MyVenues.vue @@ -0,0 +1,140 @@ + + + diff --git a/frontend/src/pages/VenueEditor.vue b/frontend/src/pages/VenueEditor.vue new file mode 100644 index 0000000..f16b114 --- /dev/null +++ b/frontend/src/pages/VenueEditor.vue @@ -0,0 +1,113 @@ + + + diff --git a/frontend/src/pages/VenuePage.vue b/frontend/src/pages/VenuePage.vue index ce91768..4f71a85 100644 --- a/frontend/src/pages/VenuePage.vue +++ b/frontend/src/pages/VenuePage.vue @@ -1,165 +1,158 @@ diff --git a/frontend/src/pages/browse/VenueCard.vue b/frontend/src/pages/browse/VenueCard.vue new file mode 100644 index 0000000..9f7c181 --- /dev/null +++ b/frontend/src/pages/browse/VenueCard.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/src/pages/checkout/MockGateway.vue b/frontend/src/pages/checkout/MockGateway.vue new file mode 100644 index 0000000..db89c81 --- /dev/null +++ b/frontend/src/pages/checkout/MockGateway.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/src/pages/confirmation/ReviewForm.vue b/frontend/src/pages/confirmation/ReviewForm.vue new file mode 100644 index 0000000..1c20a89 --- /dev/null +++ b/frontend/src/pages/confirmation/ReviewForm.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/pages/editor/AvailabilityTab.vue b/frontend/src/pages/editor/AvailabilityTab.vue new file mode 100644 index 0000000..7a36ff1 --- /dev/null +++ b/frontend/src/pages/editor/AvailabilityTab.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend/src/pages/editor/DetailsTab.vue b/frontend/src/pages/editor/DetailsTab.vue new file mode 100644 index 0000000..0de30ce --- /dev/null +++ b/frontend/src/pages/editor/DetailsTab.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/pages/editor/GalleryTab.vue b/frontend/src/pages/editor/GalleryTab.vue new file mode 100644 index 0000000..0e4f7ae --- /dev/null +++ b/frontend/src/pages/editor/GalleryTab.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/pages/editor/PricingTab.vue b/frontend/src/pages/editor/PricingTab.vue new file mode 100644 index 0000000..0ee4273 --- /dev/null +++ b/frontend/src/pages/editor/PricingTab.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/pages/editor/PublishTab.vue b/frontend/src/pages/editor/PublishTab.vue new file mode 100644 index 0000000..cdebd4a --- /dev/null +++ b/frontend/src/pages/editor/PublishTab.vue @@ -0,0 +1,99 @@ + + + diff --git a/frontend/src/pages/editor/ResourcesTab.vue b/frontend/src/pages/editor/ResourcesTab.vue new file mode 100644 index 0000000..12e2090 --- /dev/null +++ b/frontend/src/pages/editor/ResourcesTab.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/pages/editor/TimeField.vue b/frontend/src/pages/editor/TimeField.vue new file mode 100644 index 0000000..bf4f766 --- /dev/null +++ b/frontend/src/pages/editor/TimeField.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/pages/venue/ReviewList.vue b/frontend/src/pages/venue/ReviewList.vue new file mode 100644 index 0000000..10bddb5 --- /dev/null +++ b/frontend/src/pages/venue/ReviewList.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/pages/venue/SelectionCart.vue b/frontend/src/pages/venue/SelectionCart.vue new file mode 100644 index 0000000..6911944 --- /dev/null +++ b/frontend/src/pages/venue/SelectionCart.vue @@ -0,0 +1,51 @@ + + + diff --git a/frontend/src/pages/venue/SlotGrid.vue b/frontend/src/pages/venue/SlotGrid.vue new file mode 100644 index 0000000..41a052a --- /dev/null +++ b/frontend/src/pages/venue/SlotGrid.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/src/pages/venue/VenueHero.vue b/frontend/src/pages/venue/VenueHero.vue new file mode 100644 index 0000000..3304302 --- /dev/null +++ b/frontend/src/pages/venue/VenueHero.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/publisher.js b/frontend/src/publisher.js new file mode 100644 index 0000000..c7cc1a6 --- /dev/null +++ b/frontend/src/publisher.js @@ -0,0 +1,44 @@ +import { reactive } from "vue"; +import { call } from "frappe-ui"; + +// Cached publisher session shared across the /manage app. +export const session = reactive({ + loaded: false, + authenticated: false, + isPublisher: false, + user: null, + fullName: "", +}); + +const api = (name) => `apna_slot.api.publisher.${name}`; + +export async function loadSession() { + if (session.loaded) return session; + const info = await call(api("get_current_publisher")); + Object.assign(session, { + loaded: true, + authenticated: !!info.authenticated, + isPublisher: !!info.is_publisher, + user: info.user || null, + fullName: info.full_name || "", + }); + return session; +} + +// Auth gate: log in if guest, self-assign the Venue Publisher role otherwise. +export async function ensurePublisher() { + await loadSession(); + if (!session.authenticated) { + const target = window.location.pathname + window.location.search; + window.location.href = `/login?redirect-to=${encodeURIComponent(target)}`; + return false; + } + if (!session.isPublisher) { + await call(api("become_publisher")); + session.isPublisher = true; + } + return true; +} + +export const publishVenue = (venue) => call(api("publish_venue"), { venue }); +export const unpublishVenue = (venue) => call(api("unpublish_venue"), { venue }); diff --git a/frontend/src/router.js b/frontend/src/router.js index 9b474ac..343385c 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -1,12 +1,57 @@ -import { createRouter, createWebHistory } from 'vue-router' +import { createRouter, createWebHistory } from "vue-router"; +import { ensurePublisher } from "./publisher"; + +async function manageGuard() { + return (await ensurePublisher()) ? true : false; +} export const router = createRouter({ - history: createWebHistory(), - routes: [ - { - path: '/v/:slug', - name: 'Venue', - component: () => import('./pages/VenuePage.vue'), - }, - ], -}) + history: createWebHistory(), + routes: [ + { + path: "/", + name: "Browse", + component: () => import("./pages/Browse.vue"), + }, + { + path: "/v/:slug", + name: "Venue", + component: () => import("./pages/VenuePage.vue"), + }, + { + path: "/v/:slug/book", + name: "Book", + component: () => import("./pages/Checkout.vue"), + }, + { + path: "/v/:slug/confirmed/:order", + name: "Confirmed", + component: () => import("./pages/Confirmation.vue"), + }, + { + path: "/manage", + name: "MyVenues", + component: () => import("./pages/MyVenues.vue"), + beforeEnter: manageGuard, + }, + { + path: "/manage/dashboard", + name: "Dashboard", + component: () => import("./pages/Dashboard.vue"), + beforeEnter: manageGuard, + }, + { + path: "/manage/bookings", + name: "Bookings", + component: () => import("./pages/Bookings.vue"), + beforeEnter: manageGuard, + }, + { + path: "/manage/venues/:name", + name: "VenueEditor", + component: () => import("./pages/VenueEditor.vue"), + props: true, + beforeEnter: manageGuard, + }, + ], +}); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index ac955ff..9948dd5 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,12 +1,12 @@ -import frappeUIPreset from 'frappe-ui/tailwind' +import frappeUIPreset from "frappe-ui/tailwind"; /** @type {import('tailwindcss').Config} */ export default { - presets: [frappeUIPreset], - content: [ - './index.html', - './src/**/*.{vue,js,ts,jsx,tsx}', - './node_modules/frappe-ui/src/**/*.{vue,js,ts,jsx,tsx}', - './node_modules/frappe-ui/frappe/**/*.{vue,js,ts,jsx,tsx}', - ], -} + presets: [frappeUIPreset], + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + "./node_modules/frappe-ui/src/**/*.{vue,js,ts,jsx,tsx}", + "./node_modules/frappe-ui/frappe/**/*.{vue,js,ts,jsx,tsx}", + ], +}; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 9cc656f..30d8d6f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,28 +1,28 @@ -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' -import frappeui from 'frappe-ui/vite' +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import frappeui from "frappe-ui/vite"; export default defineConfig({ - plugins: [ - frappeui({ - frappeProxy: true, - lucideIcons: true, - jinjaBootData: true, - buildConfig: { - indexHtmlPath: '../apna_slot/www/v.html', - }, - }), - vue(), - ], - optimizeDeps: { - exclude: ['frappe-ui'], - include: [ - 'feather-icons', - 'tippy.js', - 'showdown', - 'engine.io-client', - 'socket.io-client', - 'debug', - ], - }, -}) + plugins: [ + frappeui({ + frappeProxy: true, + lucideIcons: true, + jinjaBootData: true, + buildConfig: { + indexHtmlPath: "../apna_slot/www/v.html", + }, + }), + vue(), + ], + optimizeDeps: { + exclude: ["frappe-ui"], + include: [ + "feather-icons", + "tippy.js", + "showdown", + "engine.io-client", + "socket.io-client", + "debug", + ], + }, +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..22f9ac0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,80 @@ +{ + "name": "apna_slot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "apna_slot", + "version": "1.0.0", + "hasInstallScript": true, + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.58.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9632f26 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "apna_slot", + "version": "1.0.0", + "description": "Slot booking made simple", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug", + "postinstall": "cd frontend && yarn install", + "dev": "cd frontend && yarn dev", + "build": "cd frontend && yarn build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bwhtech/apna_slot.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "bugs": { + "url": "https://github.com/bwhtech/apna_slot/issues" + }, + "homepage": "https://github.com/bwhtech/apna_slot#readme", + "devDependencies": { + "@playwright/test": "^1.58.2" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d59b675 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,55 @@ +import { defineConfig, devices } from "@playwright/test"; +import path from "path"; + +const authFile = path.join(__dirname, "e2e", ".auth", "user.json"); + +// Frappe multisite routing requires the Host header to match the site name. +// Node.js can't resolve .localhost TLDs, so we connect to 127.0.0.1 and set +// the Host header. Browser navigations use Chromium's --host-resolver-rules. +const SITE_HOST = process.env.SITE_HOST || "apnaslot.localhost:8000"; + +// API calls (page.request / request fixture) use Node.js DNS -> 127.0.0.1 + Host header +const API_BASE = process.env.API_BASE || "http://127.0.0.1:8000"; +// Page navigations use browser DNS -> Chromium resolver rules +const PAGE_BASE = process.env.BASE_URL || "http://apnaslot.localhost:8000"; + +export default defineConfig({ + testDir: "./e2e/tests", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "html", + timeout: 60000, + + expect: { + timeout: 10000, + }, + + use: { + baseURL: PAGE_BASE, + trace: "on-first-retry", + video: "retain-on-failure", + screenshot: "only-on-failure", + actionTimeout: 15000, + navigationTimeout: 30000, + launchOptions: { + args: [`--host-resolver-rules=MAP apnaslot.localhost 127.0.0.1`], + }, + }, + + projects: [ + { + name: "setup", + testMatch: "**/auth.setup.ts", + }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: authFile, + }, + dependencies: ["setup"], + }, + ], +}); diff --git a/scripts/REVIEWS.md b/scripts/REVIEWS.md new file mode 100644 index 0000000..5438ad1 --- /dev/null +++ b/scripts/REVIEWS.md @@ -0,0 +1,89 @@ +# QA Review — Phase 5.1 Marketplace Browse & Search + +**Commit:** `fef8124` — feat: implement Phase 5.1 marketplace browse & search +**Reviewer:** Senior QA +**Date:** 2026-07-08 +**Status:** ✅ DONE — shipped; all 4 findings resolved in follow-up `fef8124`+ (see Resolution) + +--- + +## Scope reviewed + +`apna_slot/api/public.py` (`search_venues`, `_text_filters`, `_venue_card`, `list_cities`), +`hooks.py` (`home_page = "v"`), `test_public_api.py` (4 new tests), +`frontend/src/pages/Browse.vue`, `frontend/src/pages/browse/VenueCard.vue`, `router.js`. + +## Verification done + +- **Unit/integration:** `bench run-tests --module apna_slot.tests.test_public_api` → **9/9 green** (4 new). +- **Live API:** `list_cities` → `["Bengaluru","Jagdalpur","Mumbai"]`; `search_venues` returns + Published-only card payloads with `avg_rating`/`review_count`, **no `publisher` / draft leak**. +- **Home page:** `/` serves the SPA (title `ApnaSlot`, `#app`, frontend bundle) → HTTP 200. + `/app` still 301→`/desk` for system users, so `home_page = "v"` does **not** break desk access. +- Draft-hidden, city-filter, and text-search paths all covered by tests + confirmed live. + +## What is correct + +- Published-only guard holds under text search: `filters={"status":"Published"}` AND `or_filters` + combine as AND in Frappe, so `q` never surfaces drafts (test proves it). +- Card payload leaks nothing sensitive — no `publisher`, no draft fields. +- `like` value is parameterized by Frappe → no SQL injection via `q`. +- `list_cities` correctly excludes null/empty city (`["is","set"]`) and de-dupes. +- Frontend collapses empty `city`/`q` to `undefined` — no spurious empty-string filters. + +--- + +## Findings (non-blocking) + +### 1. 🟠 MEDIUM — N+1 review query + unbounded `page_length` (guest-facing amplification) +`public.py:106,116` — `search_venues` runs one `_review_summary` query **per venue** in the page, +and `page_length` has **no upper bound**. A guest can call +`search_venues(page_length=100000)` and force ~100k review queries in one request — a cheap DB +amplification / mild DoS vector on an `allow_guest` endpoint. `_review_summary` also fetches every +review row (name/comment/creation) per venue just to compute an average the card discards. +**Fix:** cap `page_length` (e.g. `min(int(page_length), 50)`); compute avg/count with a single +`GROUP BY` aggregate over all venues in the page instead of a per-venue call. + +### 2. 🟠 MEDIUM — Search input has no debounce + stale-response race +`Browse.vue:19` — `watch([city, query], load)` fires `load()` on **every keystroke**, so typing +"mumbai" issues 6 requests (each triggering finding #1's N+1). Responses can also resolve +out of order — a slower earlier request can overwrite the latest results with stale data. +**Fix:** debounce the query watcher (~300ms) and guard against stale responses (drop a response +whose request is no longer the latest, e.g. a request token/sequence check). + +### 3. 🟡 LOW — `page_length` is deprecated in `frappe.get_all` +`public.py:104` — test run emits `V17FrappeDeprecationWarning: The 'page_length' parameter is +deprecated. Use 'limit' instead.` Works today, breaks on v17. +**Fix:** rename to `limit` in the `get_all` call. + +### 4. 🟡 LOW — `money()` ignores the card's `currency` +`VenueCard.vue` / `format.js:1` — card payload carries `currency`, but `money()` hardcodes `₹`. +Harmless while every venue is INR, but a non-INR venue would render the wrong symbol. +**Fix:** either drop `currency` from the card payload, or have `money()` honor it. + +--- + +## Verdict + +Thinnest-slice goal met: guest browse + city filter + text search are live, Published-only, +leak-free, tested, and confirmed end-to-end. No correctness or security defect blocks shipping. +Findings #1 and #2 (N+1 + unbounded page + no debounce) should be addressed when pagination/ +filters widen in the next slice, since that is exactly when the query volume grows. + +--- + +## Resolution (all findings fixed) + +- **#1** — `search_venues` now caps `page_length` at `MAX_PAGE_LENGTH = 50` and replaces the + per-venue `_review_summary` calls with one grouped query `_review_summaries` (`Avg`/`Count` + `GROUP BY venue`). No more N+1 and no guest-facing amplification. Verified live: + `_review_summaries(["274","275"])` → `{274: 4.0/1, 275: 4.5/2}` (matches the old per-venue path). +- **#2** — `Browse.vue` splits the watcher: `city` loads immediately, `query` is debounced 300ms. + `load()` guards against stale responses via a monotonic `latest` token (out-of-order responses + dropped). +- **#3** — `get_all` now passes `limit=` instead of the deprecated `page_length=`; the + `V17FrappeDeprecationWarning` is gone from the test run. +- **#4** — dropped the unused `currency` field from the card payload (the card renders only + `base_price` via `money()`), so no INR/non-INR symbol mismatch is possible. + +9/9 `test_public_api` green after the changes; frontend rebuilt. diff --git a/scripts/prompt.md b/scripts/prompt.md new file mode 100644 index 0000000..0295c98 --- /dev/null +++ b/scripts/prompt.md @@ -0,0 +1,6 @@ +IMPORTANT: Pick up the next phase and implement + +* Check @scripts/REVIEWS.md and prioritize the reviews before anything, if you fix anything in review, commit it and exit. DO NOT implement the next phase in the same cycle. Mark it done too. +* Check PROGRESS.md for current progress +* Pick the thinnest slice for implementation from the next spec +* If all specs are implemented just output "COMPLETE" and exit \ No newline at end of file diff --git a/scripts/qa_prompt.md b/scripts/qa_prompt.md new file mode 100644 index 0000000..025ef80 --- /dev/null +++ b/scripts/qa_prompt.md @@ -0,0 +1,5 @@ +You are a senior QA engineer whose job is to test changes done by a senior developer on this project's latest phase. + +ONLY the last commited changes should be reviewed. + +Write your review in @scripts/REVIEWS.md with a status and exit. \ No newline at end of file diff --git a/scripts/ralph.sh b/scripts/ralph.sh new file mode 100755 index 0000000..f28da93 --- /dev/null +++ b/scripts/ralph.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# ralph.sh — run the prompt.md prompt through Claude Code in a loop. +# +# Usage: ./ralph.sh +# +# Loops up to times. Each iteration feeds prompt.md to +# Claude Code. If any run emits "complete" (case-insensitive), the loop +# stops immediately. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROMPT_FILE="$SCRIPT_DIR/prompt.md" +QA_PROMPT_FILE="$SCRIPT_DIR/qa_prompt.md" + +max_iterations="${1:-}" + +if [[ -z "$max_iterations" || ! "$max_iterations" =~ ^[0-9]+$ ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +for file in "$PROMPT_FILE" "$QA_PROMPT_FILE"; do + if [[ ! -f "$file" ]]; then + echo "Prompt file not found: $file" >&2 + exit 1 + fi +done + +prompt="$(cat "$PROMPT_FILE")" +qa_prompt="$(cat "$QA_PROMPT_FILE")" + +for ((i = 1; i <= max_iterations; i++)); do + echo "=== ralph iteration $i/$max_iterations: implementer ===" + + output="$(claude -p "$prompt" --dangerously-skip-permissions 2>&1 | tee /dev/tty)" + + if echo "$output" | grep -qi 'complete'; then + echo "=== 'complete' emitted — stopping after iteration $i ===" + exit 0 + fi + + echo "=== ralph iteration $i/$max_iterations: QA ===" + claude -p "$qa_prompt" --dangerously-skip-permissions 2>&1 | tee /dev/tty +done + +echo "=== reached max iterations ($max_iterations) without completion ===" diff --git a/specs/00-architecture.md b/specs/00-architecture.md index 89f109d..83ce7ec 100644 --- a/specs/00-architecture.md +++ b/specs/00-architecture.md @@ -312,12 +312,13 @@ One **Vite + Vue 3 + frappe-ui** SPA at `apna_slot/frontend`, bootstrapped per t - `/v/:slug/book` — checkout: guest details, price summary, mock payment, hold countdown. - `/v/:slug/confirmed/:booking` — confirmation + optional "create account". -**Publisher (Frappe login gated):** -- `/app` — My Venues list. -- `/app/venues/:name` — venue editor (details · resources · weekly availability · exceptions · +**Publisher (Frappe login gated) — served under `/manage`** (`/app` is Frappe Desk; a website +route rule there would shadow it, so the publisher SPA lives at `/manage`): +- `/manage` — My Venues list. +- `/manage/venues/:name` — venue editor (details · resources · weekly availability · exceptions · pricing · publish toggle). -- `/app/bookings` — bookings calendar/list. -- `/app/dashboard` — earnings & upcoming (Phase 4). +- `/manage/bookings` — bookings calendar/list. +- `/manage/dashboard` — earnings & upcoming (Phase 4). **Conventions (from frappe-ui skill):** `DesktopShell` frame for the publisher app; `List` / `ListRow` for lists; `FormControl` / `TextInput` / `Select` / `Dialog` for forms; data via @@ -326,8 +327,9 @@ one-shot UI; **semantic tokens only** (`bg-surface-*`, `text-ink-*`, `border-out color = `variant` + `theme`. **Serving:** build assets emitted into `apna_slot/public/frontend`; `website_route_rules` in -`hooks.py` map `/v/` and `/app/` to the SPA's `index.html`. Publisher routes check -session and redirect to Frappe login when unauthenticated. +`hooks.py` map both `/v/` and `/manage/` to the single built web page (`www/v.html`), +and vue-router dispatches by path. Publisher routes check session and redirect to Frappe login +when unauthenticated. --- diff --git a/specs/README.md b/specs/README.md index b5299fb..5cabbc6 100644 --- a/specs/README.md +++ b/specs/README.md @@ -58,7 +58,7 @@ de-risk the architecture before the fuller phases widen it. | **2** | [phase-2-public-venue-availability.md](phase-2-public-venue-availability.md) | Public link shows the venue + live slot availability & prices | | **3** | [phase-3-booking-locking-payment.md](phase-3-booking-locking-payment.md) | End-to-end booking of one or **multiple slots** with slot locking + mock payment | | **4** | [phase-4-notifications-reviews-dashboard.md](phase-4-notifications-reviews-dashboard.md) | Email confirmations, reviews, publisher dashboard — **MVP complete** | -| **5** | [phase-5-roadmap-deferred.md](phase-5-roadmap-deferred.md) | Roadmap: cancellation, marketplace, real payments, commission, etc. | +| **5** | [phase-5-roadmap-deferred.md](phase-5-roadmap-deferred.md) | Post-MVP, sequenced 5.1–5.8: marketplace, My Bookings, notification channels, real payments, cancellation/refunds, commission, per-resource schedules, hardening | **Shared reference:** [00-architecture.md](00-architecture.md) — the full data model, roles & permissions, slot-locking design, pricing engine, API surface, and frontend architecture that diff --git a/specs/phase-1-publisher-venue-management.md b/specs/phase-1-publisher-venue-management.md index de111d4..daeadfa 100644 --- a/specs/phase-1-publisher-venue-management.md +++ b/specs/phase-1-publisher-venue-management.md @@ -47,29 +47,32 @@ in `hooks.py` `permission_query_conditions` + `has_permission` (00-architecture ## Frontend -Publisher SPA routes (00-architecture §6): `/app` and `/app/venues/:name`. +Publisher SPA routes (00-architecture §6): `/manage` and `/manage/venues/:name` (`/app` is Frappe +Desk — see architecture §6). - **Auth gate:** unauthenticated → redirect to Frappe login; authenticated non-publisher → - prompt/assign publisher role. -- **My Venues** (`/app`): `DesktopShell` + `List`/`ListRow` of the publisher's venues (name, - city, status badge, resource count). "New Venue" → `Dialog` with `FormControl` for name → - creates via `useDoc`/`useCall`, routes to editor. Data via `useList('Venue', …)`. -- **Venue editor** (`/app/venues/:name`): `useDoc('Venue', name)` with sections: - - **Details** — `venue_name`, `city`, `address`, `contact_phone`, `base_price`; `Editor` - (from `frappe-ui/editor`) for `description` and `rules`; cover image upload. + `become_publisher` self-assigns the Venue Publisher role. +- **My Venues** (`/manage`): top-bar `ManageShell` + `divide-y` rows of the publisher's venues + (name, city, status badge, resource count). "New Venue" → `Dialog` with `FormControl` for + name + base price → creates via `call('frappe.client.insert')`, routes to editor. +- **Venue editor** (`/manage/venues/:name`): loads via `call('frappe.client.get')` into a + reactive doc, saved via `call('frappe.client.save')`, with tabbed sections: + - **Details** — `venue_name`, `city`, `address`, `contact_phone`, `base_price`; `textarea` + for `description` and `rules` (rich `Editor` deferred); cover image upload (`FileUploader`). - **Gallery** — add/remove images (`gallery` child rows). - **Resources** — inline list to add/rename/toggle resources (`Venue Resource` docs linked to this venue). - **Availability** — 7 weekday rows (open/close/slot length, `is_open` toggle) + an exceptions sub-list (date, type, hours). - **Pricing** — base price + a table of pricing rules (label, day-type, time band, surcharge). - - **Publish** — status pill + Publish/Unpublish button (`dialog.confirm`), and the shareable + - **Publish** — status pill + Publish/Unpublish button (`confirmDialog`), and the shareable URL (`/v/:slug`) shown with copy-to-clipboard once Published. -- Writes use `immediate: false` + explicit `submit()` (frappe-ui rule 5). +- Doc I/O uses imperative `call('frappe.client.…')`; publish/unpublish call the whitelisted + `apna_slot.api.publisher` wrappers (accept `str | int` names). ## Acceptance criteria -- A new user can become a publisher and reach `/app`. +- A new user can become a publisher and reach `/manage`. - Creating a venue auto-generates a unique slug; two venues named the same get distinct slugs. - A venue with resources + open weekdays + base price can be Published; `published_on` set; shareable URL shown. Missing resource or all-closed week blocks publish with a clear error. diff --git a/specs/phase-2-public-venue-availability.md b/specs/phase-2-public-venue-availability.md index 36370e0..9fb035b 100644 --- a/specs/phase-2-public-venue-availability.md +++ b/specs/phase-2-public-venue-availability.md @@ -74,3 +74,19 @@ Public routes (00-architecture §6): `/v/:slug`. Creating a hold/booking and payment (Phase 3), reviews display feeding `avg_rating` (Phase 4), marketplace browse/search (Phase 5). + +## Implementation notes (reconciled) + +- `engine/slots.py::_hours_for` resolves the day's open/close/duration once (exception wins over + the weekly row); midnight-crossing adds 24h to close and `_format` wraps times mod 24h, so a + post-midnight slot's `start` is e.g. `00:00:00` while `booking_date` stays the opening date. +- `api/public.py::_assert_public_resource` guards `get_slots` (resource `Active` + venue + `Published`); `get_public_venue` returns gallery + safe fields only, `avg_rating`/`review_count` + hard-coded `0` until Phase 4. +- Frontend split: `VenueHero` / `SlotGrid` / `SelectionCart` + `format.js`; the client-side cart + spans resources & dates. **Date picker is a native ``** (min = today) rather + than frappe-ui `DatePicker` — reliable and automation-friendly. +- The **Book CTA routes to `/v/:slug/book`, a stub** (`BookStub.vue`) that reads the selection from + `history.state`; the real hold + mock-payment checkout is Phase 3. +- `prose` typography classes are present but no-op (no `@tailwindcss/typography` plugin); rich-text + `description`/`rules` still render via `v-html`. diff --git a/specs/phase-4-notifications-reviews-dashboard.md b/specs/phase-4-notifications-reviews-dashboard.md index 229db9c..98e350e 100644 --- a/specs/phase-4-notifications-reviews-dashboard.md +++ b/specs/phase-4-notifications-reviews-dashboard.md @@ -59,12 +59,12 @@ is rejected; the venue page's average and count update; a non-confirmed booking `amount` on Confirmed bookings (no commission in MVP → publisher revenue = gross). **Frontend:** -- **Dashboard** route (`/app/dashboard`, 00-architecture §6): stat tiles (today's bookings, - week revenue, upcoming count) + upcoming bookings list. Use frappe-ui `List`/`ListRow`; - follow the `dataviz` guidance if adding a small chart. -- **Bookings** route (`/app/bookings`): list/calendar of the publisher's booking lines - filterable by venue, resource, date, status (`useList('Booking', …)`, permission-scoped by - §5); multi-slot orders are grouped by their `order` link. +- **Dashboard** route (`/manage/dashboard`, 00-architecture §6 — the publisher SPA lives under + `/manage`, not `/app` which is Frappe Desk): stat tiles (today's bookings, week revenue, + upcoming count) + upcoming bookings list + per-venue breakdown. +- **Bookings** route (`/manage/bookings`): list of the publisher's booking lines filterable by + venue and status (`frappe.client.get_list('Booking', …)`, permission-scoped by §5); multi-slot + orders are grouped by their `order` link. **Acceptance:** dashboard shows only the logged-in publisher's numbers; revenue matches the sum of that publisher's Confirmed bookings; another publisher sees different figures; bookings list diff --git a/specs/phase-5-roadmap-deferred.md b/specs/phase-5-roadmap-deferred.md index 481de0e..9686e01 100644 --- a/specs/phase-5-roadmap-deferred.md +++ b/specs/phase-5-roadmap-deferred.md @@ -1,56 +1,238 @@ -# Phase 5 — Roadmap / Deferred +# Phase 5 — Post-MVP (sequenced, ready for implementation) -Explicitly **out of the MVP** (Phases 1–4). Captured here so the MVP schema stays forward- -compatible. Not ordered; pick per demand. Each references how the MVP already leaves room. +The MVP (Phases 0–4) is complete. This phase widens it into a marketplace product. The eight +deferred items are now **ordered into tracer-bullet sub-phases** — each a thin vertical slice +through every layer, independently shippable, building on the ones before it. Build in order. -## 1. Real payment gateway +**Ordering rationale.** Marketplace browse/search, My Bookings, and notification channels come +first: they are the highest-value next steps and need **no payment work**. Real payments (5.4) +then unblocks the money-dependent slices (cancellation/refunds, monetization). Schema-touching +and operational work land last. -Swap the mock for a real gateway using the bench's existing **`payments`** / **`razorpay_frappe`** -(or `payu`) app. The `Payment Transaction` DocType (00-architecture §1.6) already carries -`gateway` / `gateway_ref` and is the only integration seam. `confirm_payment` becomes a -verify-signature/callback step instead of a pass-through; `hold_slot` still creates the Pending -payment. Booking flow and slot locking are unchanged. +Shared reference: [00-architecture.md](00-architecture.md). Each sub-phase notes the seams the +MVP already left open. -## 2. Customer self-cancellation & refunds +**Legend:** ⬜ ready · 🔄 in progress · ✅ done -Per-venue cancellation policy (e.g. free > 24h before start), a customer "Cancel booking" -action, and a refund path (dependent on real payments). On cancel: Booking → `Cancelled`, -`slot_key` rewritten to the inactive form (§2) → slot frees immediately. Add `cancelled_on`, -`refund_status`, and policy fields on Venue. +| Sub-phase | Slice | Depends on | Status | +|---|---|---|---| +| **5.1** | Marketplace browse & search | MVP | ✅ | +| **5.2** | Customer account area — "My Bookings" | MVP | ⬜ | +| **5.3** | Additional notification channels | Phase 4 email trigger | ⬜ | +| **5.4** | Real payment gateway | MVP | ⬜ | +| **5.5** | Customer self-cancellation & refunds | 5.4 | ⬜ | +| **5.6** | Platform monetization / commission | 5.4 | ⬜ | +| **5.7** | Per-resource custom schedules | MVP | ⬜ | +| **5.8** | Operational hardening | as needed | ⬜ | -## 3. Marketplace browse & search +--- -A public homepage listing all Published venues with filters (city, sport/category, date -availability) and venue search — beyond the shareable-link discovery of the MVP. Reuses the -existing public APIs; adds a `search_venues(...)` endpoint and public browse routes. +## 5.1 — Marketplace browse & search ✅ -## 4. Platform monetization +**Shipped (thinnest slice).** `search_venues(city, q, start, page_length)` + `list_cities()` +(both `allow_guest`, Published-only, card payload — no `publisher`/draft leak) → `/` browse page +(`Browse.vue` + `VenueCard.vue`) with a live **city** filter and text search over name/city. +`home_page = "v"` serves the SPA at the site root; the card grid reuses the public-venue look. +Card click → `/v/:slug`. **Deferred to the next widening:** the `category` filter (no venue-level +category field yet — category lives on Venue Resource), `date` availability filtering +(`get_slots_for` reuse), pagination UI, and a separate `/search` route (the single `/` page does +browse + search + filter). Tests: 4 new in `test_public_api.py` (published-only / drafts & +`publisher` hidden, city filter, text match, `list_cities`) — 53 total green. agent-browser e2e +green (`/` lists 6 Published venues → Mumbai filter → 1 card → opens `/v/sunrise-sports-club`). -Commission or fees introduced in 00-architecture's decisions as "none for MVP". Options: -per-booking % commission, flat convenience fee, or publisher subscription. Adds platform-fee / -net-payout fields on Booking or Payment and a payout ledger; pairs with real payments. +**Goal.** A public homepage lists every Published venue with filters and search, so customers +discover venues beyond a shared link. -## 5. Per-resource custom schedules +**Tracer bullet.** `search_venues()` returning all Published venues → a `/` browse page +rendering venue cards → one filter (city) live. Widen to more filters after the thread is green. -MVP shares availability + pricing at the venue level. Allow a resource to **override** the -venue's weekly template, exceptions, or pricing (e.g. a premium court with different hours or -price). Move (or duplicate) the availability/pricing child tables onto `Venue Resource`, with -"inherit from venue" as the default. +**Backend** (`api/public.py`, `allow_guest=True`, Published-only, `ignore_permissions=True`): +- `search_venues(city=None, category=None, date=None, q=None, start=0, page_length=20)` → + `[{slug, venue_name, city, category, cover_image, base_price, currency, avg_rating, + review_count}]`. Reuses the Phase 2 public payload shape; leaks no `publisher`/draft data. +- `date` availability filtering reuses `engine/slots.get_slots_for` (a venue matches if any + resource has an open slot that date). -## 6. Customer account area ("My Bookings") +**Frontend.** Public routes: `/` (browse) and `/search`. Venue-card grid, filter bar (city, +category, date, text), empty/loading states. Mobile-first, no auth. Reuse the venue card look +from the public venue page. -The MVP already supports optional account creation and links `customer` on bookings. Build a -customer-facing area to view upcoming/past bookings, re-book, and manage profile. +**Tests.** Only Published venues returned; each filter narrows correctly; text search matches +name/city; pagination; a draft venue never appears. -## 7. Additional notification channels +**Acceptance.** `/` lists Published venues; filtering by city + date shows only matching venues +with availability; clicking a card opens `/v/:slug`; drafts never surface (agent-browser e2e). -SMS / WhatsApp booking confirmations and reminders (India-common for turf), reminder before -slot start, and publisher digest emails. Extends the Phase 4 email trigger with more channels. +--- -## 8. Operational hardening (as needed) +## 5.2 — Customer account area — "My Bookings" ⬜ -- Admin moderation/approval before a venue goes public. -- Multi-currency / locale beyond INR. -- Recurring bookings (book the same slot weekly). -- Waitlist for fully-booked slots. -- Analytics exports / reports for publishers. +**Goal.** A logged-in customer views their upcoming and past bookings and re-books a slot. The +MVP already offers optional account creation and links `customer` on the Booking Order. + +**Tracer bullet.** `get_my_bookings()` for the logged-in user → `/my/bookings` list → one +booking's detail. Re-book and profile edit follow. + +**Backend** (`api/customer.py`, auth required, scoped to `frappe.session.user`): +- `get_my_bookings()` → upcoming/past Booking Orders (with their slot lines) where + `customer` = the session user. Never returns another customer's data. +- Permission scoping: Booking / Booking Order gain a **customer** read path alongside the + Phase 4 publisher scoping (`permission_query_conditions` + `has_permission` — extend the + shared helper, don't fork it). + +**Frontend.** Authenticated customer routes `/my/bookings` (upcoming + past tabs) and +`/my/bookings/:order`. "Re-book" pre-fills the venue page cart with the same slot on a new date. +Optional profile edit (name/phone/email). Reuse `ManageShell`-style nav for customers. + +**Tests.** A customer sees only their own orders; upcoming vs past split by slot datetime; +cross-customer isolation; re-book routes to the venue with the slot pre-selected. + +**Acceptance.** Customer logs in → sees own upcoming/past bookings → opens one → re-books it; +another customer's bookings are never visible (agent-browser e2e). + +--- + +## 5.3 — Additional notification channels ⬜ + +**Goal.** Extend the Phase 4 email-on-confirm trigger with SMS/WhatsApp confirmations, a +pre-slot reminder, and a publisher digest — India-common for turf bookings. + +**Tracer bullet.** SMS confirmation on the existing Held→Confirmed transition (reuses the +Phase 4 `on_update` guard + `enqueue`) → then reminder-before-slot → then digest. + +**Backend** (extend `notifications.py`): +- Channel abstraction: `build_confirmation_messages` already pure/testable — add SMS/WhatsApp + renderers behind a small channel interface; send is best-effort (an outage never fails a + paid booking, same guarantee as Phase 4). +- **Reminder:** a scheduled task (`tasks.py`) fires a message N minutes before slot start for + Confirmed bookings; idempotent (a `reminder_sent` flag on Booking). +- **Publisher digest:** daily scheduled email summarising the publisher's upcoming bookings. +- Provider config (SMS/WhatsApp gateway keys) in a single Settings DocType; channels toggle + per venue. + +**Tests.** Confirmation fans out to enabled channels only; reminder fires once (idempotent) and +only for Confirmed future slots; digest scoped per publisher; mail/SMS outage never raises. + +**Acceptance.** Confirming a booking sends email + SMS; a reminder arrives once before the slot; +publisher receives a daily digest (agent-browser / log assertion e2e). + +--- + +## 5.4 — Real payment gateway ⬜ + +**Goal.** Swap the mock gateway for a real one using the bench's existing `payments` / +`razorpay_frappe` (or `payu`) app. Booking flow and slot locking are **unchanged** — the +`Payment Transaction` DocType (00-architecture §1.6) is the only integration seam. + +**Tracer bullet.** `hold_slots` still creates the Pending `Payment Transaction`; the checkout +launches the real gateway; `confirm_payment` becomes a **verify-signature/callback** step +instead of a pass-through. One end-to-end sandbox payment confirms a booking. + +**Backend.** +- `hold_slots` unchanged (creates Pending payment + 10-min hold). +- Replace `confirm_payment` pass-through with gateway verification: on a valid + signature/callback, mark the `Payment Transaction` captured (store `gateway` / `gateway_ref`) + and drive the Booking Order Held→Confirmed. Reject invalid/duplicate callbacks. +- Handle the abandoned-payment path: hold auto-expiry (Phase 3 `expire_holds`) already frees + the slot if payment never completes. + +**Frontend.** Checkout launches the gateway (redirect or SDK) instead of the mock button; the +confirmation page is reached via the verified callback. Keep the 10-min countdown. + +**Tests.** Valid signature → Confirmed; tampered/invalid signature → rejected, slot stays held +then expires; duplicate callback is idempotent; hold expiry frees the slot when payment never +returns. + +**Acceptance.** A sandbox payment confirms a real booking and locks the slot; a failed/abandoned +payment leaves the slot free after expiry (agent-browser e2e against gateway sandbox). + +--- + +## 5.5 — Customer self-cancellation & refunds ⬜ + +**Goal.** A customer cancels a booking under a per-venue policy; the slot frees immediately and +(where policy allows) a refund is issued. Depends on real payments (5.4). + +**Tracer bullet.** Cancel with no refund first — Booking → `Cancelled`, `slot_key` rewritten to +the inactive form (00-architecture §2) so the slot frees. Layer the refund path on after. + +**Backend.** +- Venue gains cancellation-policy fields (e.g. free if > N hours before start); Booking gains + `cancelled_on` + `refund_status`. +- `cancel_booking(order_or_booking)` (auth, owner-scoped): flips status, rewrites `slot_key` to + the inactive form → slot frees, evaluates policy, and (if refundable) triggers a gateway + refund via the 5.4 seam. + +**Frontend.** "Cancel booking" action in **My Bookings** (5.2) showing the policy outcome +(free / partial / non-refundable) before confirming. + +**Tests.** Cancel frees the slot immediately (re-bookable); policy computes refund correctly at +boundaries (just over / just under the cutoff); refund status transitions; non-owner cannot +cancel. + +**Acceptance.** Customer cancels within the free window → slot frees + refund issued; cancelling +inside the cutoff frees the slot with no/partial refund per policy (agent-browser e2e). + +--- + +## 5.6 — Platform monetization / commission ⬜ + +**Goal.** Introduce platform revenue (00-architecture decisions record "none for MVP"). Depends +on real payments (5.4). Start with a per-booking % commission; convenience fee / subscription +are variants of the same seam. + +**Tracer bullet.** Compute + persist a commission split on each confirmed payment (platform fee +vs publisher net) → surface it → a simple payout ledger. + +**Backend.** +- Platform-fee / net-payout fields on Booking or Payment Transaction; a commission rate in + platform settings (optionally per-venue override). +- On confirmation, split the amount: `platform_fee` + `publisher_net`; record both. A **payout + ledger** DocType accrues publisher balances. + +**Frontend.** Publisher dashboard (Phase 4) shows gross / fee / net and a payout summary. + +**Tests.** Split math (rounding to currency minor units); net = gross − fee; ledger accrues per +publisher; a zero-rate venue behaves like the MVP (full credit). + +**Acceptance.** A confirmed booking records the correct fee + net; the publisher dashboard shows +net earnings and payout balance (unit + agent-browser e2e). + +--- + +## 5.7 — Per-resource custom schedules ⬜ + +**Goal.** Let a resource **override** the venue's weekly template, exceptions, or pricing (e.g. +a premium court with different hours/price). MVP shares availability + pricing at venue level. + +**Tracer bullet.** One override — resource-level weekly hours — with "inherit from venue" as the +default, threaded through `engine/slots.get_slots_for`. Extend to exceptions + pricing after. + +**Backend.** +- Duplicate the availability/pricing child tables onto `Venue Resource` with an `inherit_from_venue` + default; `engine/slots` + `engine/pricing` resolve resource override first, else venue. +- Backward-compatible: existing venues keep working (inherit = on). + +**Frontend.** Resource editor (Phase 1 SPA) gains an "Override venue schedule/pricing" toggle +that reveals the resource-level tables. + +**Tests.** Inheriting resource matches venue slots/prices; overriding resource uses its own +hours/pricing; mixed resources in one venue resolve independently; existing data unaffected. + +**Acceptance.** A premium resource with custom hours + price shows different slots/prices than a +sibling that inherits, on the same venue (unit + agent-browser e2e). + +--- + +## 5.8 — Operational hardening ⬜ + +Ongoing backlog; pick per demand. Each is an independent slice on the same seams above. + +- **Admin moderation** — approval before a venue goes public (adds a review state to the + publish state machine). +- **Multi-currency / locale** beyond INR. +- **Recurring bookings** — book the same slot weekly (generates a series of holds/bookings). +- **Waitlist** for fully-booked slots (notify when a hold expires / a booking cancels). +- **Analytics exports / reports** for publishers. + +Each ships with its own tests + acceptance when scheduled.