From 37c8980fb00f25104cc5e20b75ad99dca6d0d8c4 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Tue, 7 Jul 2026 22:10:50 +0530 Subject: [PATCH 01/13] feat: implement Phase 1 publisher venue management Full venue-side schema, roles, permissions, and the publisher SPA at /manage. - DocTypes: full Venue (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; become_publisher onboarding - venue.py: unique-slug de-dupe, seed 7 weekday rows, publish/unpublish guards - permissions.py: venue_query/venue_resource_query + has_permission, wired in hooks - api/publisher.py: get_current_publisher, become_publisher, publish/unpublish - SPA: /manage My Venues + venue editor (Details/Resources/Availability/Pricing/ Gallery/Publish); one www page serves /v and /manage - Tests: slug uniqueness, publish guards, cross-publisher isolation (17 total green) Deviations from spec (reconciled in specs/): publisher app at /manage not /app (Desk collision); frappe-ui 0.1.278 uses confirmDialog + Dialog v-model; doc I/O via imperative call('frappe.client.*'); publish wrappers accept str|int names. Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 34 +++-- apna_slot/api/publisher.py | 53 +++++++ apna_slot/apna_slot/doctype/venue/venue.json | 140 +++++++++++++++++- apna_slot/apna_slot/doctype/venue/venue.py | 72 ++++++++- .../venue_availability_exception/__init__.py | 0 .../venue_availability_exception.json | 56 +++++++ .../venue_availability_exception.py | 5 + .../doctype/venue_gallery/__init__.py | 0 .../doctype/venue_gallery/venue_gallery.json | 38 +++++ .../doctype/venue_gallery/venue_gallery.py | 5 + .../doctype/venue_pricing_rule/__init__.py | 0 .../venue_pricing_rule.json | 60 ++++++++ .../venue_pricing_rule/venue_pricing_rule.py | 5 + .../venue_resource/venue_resource.json | 29 +++- apna_slot/hooks.py | 22 ++- apna_slot/patches.txt | 1 + .../patches/create_venue_publisher_role.py | 14 ++ apna_slot/permissions.py | 45 ++++++ apna_slot/tests/test_permissions.py | 54 +++++++ apna_slot/tests/test_venue.py | 74 +++++++++ frontend/src/App.vue | 3 +- frontend/src/pages/ManageShell.vue | 20 +++ frontend/src/pages/MyVenues.vue | 117 +++++++++++++++ frontend/src/pages/VenueEditor.vue | 95 ++++++++++++ frontend/src/pages/editor/AvailabilityTab.vue | 79 ++++++++++ frontend/src/pages/editor/DetailsTab.vue | 48 ++++++ frontend/src/pages/editor/GalleryTab.vue | 45 ++++++ frontend/src/pages/editor/PricingTab.vue | 46 ++++++ frontend/src/pages/editor/PublishTab.vue | 84 +++++++++++ frontend/src/pages/editor/ResourcesTab.vue | 83 +++++++++++ frontend/src/pages/editor/TimeField.vue | 23 +++ frontend/src/publisher.js | 44 ++++++ frontend/src/router.js | 18 +++ specs/00-architecture.md | 16 +- specs/phase-1-publisher-venue-management.md | 25 ++-- 35 files changed, 1408 insertions(+), 45 deletions(-) create mode 100644 apna_slot/api/publisher.py create mode 100644 apna_slot/apna_slot/doctype/venue_availability_exception/__init__.py create mode 100644 apna_slot/apna_slot/doctype/venue_availability_exception/venue_availability_exception.json create mode 100644 apna_slot/apna_slot/doctype/venue_availability_exception/venue_availability_exception.py create mode 100644 apna_slot/apna_slot/doctype/venue_gallery/__init__.py create mode 100644 apna_slot/apna_slot/doctype/venue_gallery/venue_gallery.json create mode 100644 apna_slot/apna_slot/doctype/venue_gallery/venue_gallery.py create mode 100644 apna_slot/apna_slot/doctype/venue_pricing_rule/__init__.py create mode 100644 apna_slot/apna_slot/doctype/venue_pricing_rule/venue_pricing_rule.json create mode 100644 apna_slot/apna_slot/doctype/venue_pricing_rule/venue_pricing_rule.py create mode 100644 apna_slot/patches/create_venue_publisher_role.py create mode 100644 apna_slot/permissions.py create mode 100644 apna_slot/tests/test_permissions.py create mode 100644 apna_slot/tests/test_venue.py create mode 100644 frontend/src/pages/ManageShell.vue create mode 100644 frontend/src/pages/MyVenues.vue create mode 100644 frontend/src/pages/VenueEditor.vue create mode 100644 frontend/src/pages/editor/AvailabilityTab.vue create mode 100644 frontend/src/pages/editor/DetailsTab.vue create mode 100644 frontend/src/pages/editor/GalleryTab.vue create mode 100644 frontend/src/pages/editor/PricingTab.vue create mode 100644 frontend/src/pages/editor/PublishTab.vue create mode 100644 frontend/src/pages/editor/ResourcesTab.vue create mode 100644 frontend/src/pages/editor/TimeField.vue create mode 100644 frontend/src/publisher.js diff --git a/PROGRESS.md b/PROGRESS.md index 3756635..0b2d280 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,7 +10,7 @@ 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) | ⬜ | +| **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 | ⬜ | @@ -30,15 +30,15 @@ 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 ⬜ +## 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) +- [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 ⬜ @@ -71,6 +71,22 @@ 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-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/apna_slot/api/publisher.py b/apna_slot/api/publisher.py new file mode 100644 index 0000000..24526a3 --- /dev/null +++ b/apna_slot/api/publisher.py @@ -0,0 +1,53 @@ +"""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.utils import get_fullname + +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 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/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/hooks.py b/apna_slot/hooks.py index 82b2a6c..d67c576 100644 --- a/apna_slot/hooks.py +++ b/apna_slot/hooks.py @@ -126,13 +126,16 @@ # ----------- # 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 + resources (System Managers unscoped). +permission_query_conditions = { + "Venue": "apna_slot.permissions.venue_query", + "Venue Resource": "apna_slot.permissions.venue_resource_query", +} + +has_permission = { + "Venue": "apna_slot.permissions.has_venue_permission", + "Venue Resource": "apna_slot.permissions.has_venue_resource_permission", +} # Document Events # --------------- @@ -170,9 +173,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 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..a0c13e4 --- /dev/null +++ b/apna_slot/permissions.py @@ -0,0 +1,45 @@ +"""Row-level scoping: a publisher only ever sees their own venues and resources. + +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: + user = user or frappe.session.user + if is_admin(user): + return "" + return ( + "`tabVenue Resource`.venue in " + 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): + user = user or frappe.session.user + if is_admin(user): + return True + return frappe.db.get_value("Venue", doc.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/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_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/frontend/src/App.vue b/frontend/src/App.vue index 1856c0d..1d1d619 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,10 @@ diff --git a/frontend/src/pages/ManageShell.vue b/frontend/src/pages/ManageShell.vue new file mode 100644 index 0000000..8ff8ed1 --- /dev/null +++ b/frontend/src/pages/ManageShell.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/pages/MyVenues.vue b/frontend/src/pages/MyVenues.vue new file mode 100644 index 0000000..eab8f16 --- /dev/null +++ b/frontend/src/pages/MyVenues.vue @@ -0,0 +1,117 @@ + + + diff --git a/frontend/src/pages/VenueEditor.vue b/frontend/src/pages/VenueEditor.vue new file mode 100644 index 0000000..dbae781 --- /dev/null +++ b/frontend/src/pages/VenueEditor.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/pages/editor/AvailabilityTab.vue b/frontend/src/pages/editor/AvailabilityTab.vue new file mode 100644 index 0000000..559fa00 --- /dev/null +++ b/frontend/src/pages/editor/AvailabilityTab.vue @@ -0,0 +1,79 @@ + + + diff --git a/frontend/src/pages/editor/DetailsTab.vue b/frontend/src/pages/editor/DetailsTab.vue new file mode 100644 index 0000000..98c7c58 --- /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..972742c --- /dev/null +++ b/frontend/src/pages/editor/GalleryTab.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/pages/editor/PricingTab.vue b/frontend/src/pages/editor/PricingTab.vue new file mode 100644 index 0000000..000a8cd --- /dev/null +++ b/frontend/src/pages/editor/PricingTab.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/pages/editor/PublishTab.vue b/frontend/src/pages/editor/PublishTab.vue new file mode 100644 index 0000000..0e7bc3f --- /dev/null +++ b/frontend/src/pages/editor/PublishTab.vue @@ -0,0 +1,84 @@ + + + diff --git a/frontend/src/pages/editor/ResourcesTab.vue b/frontend/src/pages/editor/ResourcesTab.vue new file mode 100644 index 0000000..316d555 --- /dev/null +++ b/frontend/src/pages/editor/ResourcesTab.vue @@ -0,0 +1,83 @@ + + + diff --git a/frontend/src/pages/editor/TimeField.vue b/frontend/src/pages/editor/TimeField.vue new file mode 100644 index 0000000..c731e86 --- /dev/null +++ b/frontend/src/pages/editor/TimeField.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/publisher.js b/frontend/src/publisher.js new file mode 100644 index 0000000..57b9d65 --- /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..3f1e073 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -1,4 +1,9 @@ import { createRouter, createWebHistory } from 'vue-router' +import { ensurePublisher } from './publisher' + +async function manageGuard() { + return (await ensurePublisher()) ? true : false +} export const router = createRouter({ history: createWebHistory(), @@ -8,5 +13,18 @@ export const router = createRouter({ name: 'Venue', component: () => import('./pages/VenuePage.vue'), }, + { + path: '/manage', + name: 'MyVenues', + component: () => import('./pages/MyVenues.vue'), + beforeEnter: manageGuard, + }, + { + path: '/manage/venues/:name', + name: 'VenueEditor', + component: () => import('./pages/VenueEditor.vue'), + props: true, + beforeEnter: manageGuard, + }, ], }) 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/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. From 5f767efbded547e85e468717954e4ccf6143ba06 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Tue, 7 Jul 2026 22:25:29 +0530 Subject: [PATCH 02/13] feat: implement Phase 2 public venue page & live availability Full pricing engine (additive surcharge loop over day-type + time-band) and slot engine gains exceptions (Closed / Special Hours override), midnight crossing, and past-slot filtering. Public API returns a guest-safe venue payload (gallery + details, no publisher/draft leak) and guards get_slots to Published venues + Active resources. Rebuilt public /v/:slug page into VenueHero + SlotGrid + SelectionCart with a client-side multi-slot cart spanning resources/dates; Book CTA carries the selection to a /v/:slug/book stub (real checkout is Phase 3). 13 new tests (30 total green); agent-browser e2e verifies weekend-evening price stacking, Closed/Special-Hours days, and the unpublished "not available" path. Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 28 ++- apna_slot/api/public.py | 42 ++++- apna_slot/engine/pricing.py | 31 +++- apna_slot/engine/slots.py | 61 +++++-- apna_slot/tests/test_pricing.py | 42 +++++ apna_slot/tests/test_public_api.py | 41 +++++ apna_slot/tests/test_slots.py | 32 ++++ frontend/src/format.js | 18 ++ frontend/src/pages/BookStub.vue | 50 ++++++ frontend/src/pages/VenuePage.vue | 194 ++++++++++----------- frontend/src/pages/venue/SelectionCart.vue | 49 ++++++ frontend/src/pages/venue/SlotGrid.vue | 55 ++++++ frontend/src/pages/venue/VenueHero.vue | 64 +++++++ frontend/src/router.js | 5 + specs/phase-2-public-venue-availability.md | 16 ++ 15 files changed, 591 insertions(+), 137 deletions(-) create mode 100644 apna_slot/tests/test_pricing.py create mode 100644 apna_slot/tests/test_public_api.py create mode 100644 frontend/src/format.js create mode 100644 frontend/src/pages/BookStub.vue create mode 100644 frontend/src/pages/venue/SelectionCart.vue create mode 100644 frontend/src/pages/venue/SlotGrid.vue create mode 100644 frontend/src/pages/venue/VenueHero.vue diff --git a/PROGRESS.md b/PROGRESS.md index 0b2d280..1beb0d9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -11,7 +11,7 @@ model / mechanisms are in [`specs/00-architecture.md`](specs/00-architecture.md) |---|---|---| | **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) | ⬜ | +| **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 | @@ -40,13 +40,13 @@ Thinnest thread; validates the spine (slot-lock + server pricing) before widenin - [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 ⬜ +## 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) +- [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 ⬜ @@ -71,6 +71,20 @@ 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-07** — Phase 2 shipped. Full pricing engine (`compute_price` surcharge loop: day-type + + time-band, additive); slot engine gains exceptions (Closed / Special Hours override) + + midnight-crossing (`close <= open` → +24h, times wrap mod 24h) + past-slot filter; `get_public_venue` + full safe payload (city/address/description/rules/cover/gallery/contact, `avg_rating`/`review_count`=0) + + resource `category`/`sort_order`; `get_slots` guarded by `_assert_public_resource` (venue Published + + resource Active). Public page rebuilt: `VenueHero` (cover/gallery/rich-text rules) + `SlotGrid` + (priced chips, multi-select, skeleton/closed states) + `SelectionCart` (sticky summary, remove, Book + CTA) + `format.js` helpers; `BookStub` at `/v/:slug/book` carries selection via router state. 13 new + tests (30 total green); agent-browser e2e green (weekend evening = base+weekend+peak; Closed/Special + Hours; unpublished "not available"). Deviations: (1) 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, diff --git a/apna_slot/api/public.py b/apna_slot/api/public.py index 680c10d..ea4c933 100644 --- a/apna_slot/api/public.py +++ b/apna_slot/api/public.py @@ -24,26 +24,50 @@ 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.""" + 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], + "avg_rating": 0, # Phase 4 + "review_count": 0, # Phase 4 + } + + @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. diff --git a/apna_slot/engine/pricing.py b/apna_slot/engine/pricing.py index 5bee5f7..21d4ab2 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..0b8a5dc 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 − exceptions (Closed / Special Hours) − 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/tests/test_pricing.py b/apna_slot/tests/test_pricing.py new file mode 100644 index 0000000..ef424d3 --- /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..19998db --- /dev/null +++ b/apna_slot/tests/test_public_api.py @@ -0,0 +1,41 @@ +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 +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]) 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/frontend/src/format.js b/frontend/src/format.js new file mode 100644 index 0000000..f3ef5bd --- /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/pages/BookStub.vue b/frontend/src/pages/BookStub.vue new file mode 100644 index 0000000..744a667 --- /dev/null +++ b/frontend/src/pages/BookStub.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/pages/VenuePage.vue b/frontend/src/pages/VenuePage.vue index ce91768..0f372ec 100644 --- a/frontend/src/pages/VenuePage.vue +++ b/frontend/src/pages/VenuePage.vue @@ -1,33 +1,48 @@ diff --git a/frontend/src/pages/venue/SelectionCart.vue b/frontend/src/pages/venue/SelectionCart.vue new file mode 100644 index 0000000..f5aba4a --- /dev/null +++ b/frontend/src/pages/venue/SelectionCart.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/pages/venue/SlotGrid.vue b/frontend/src/pages/venue/SlotGrid.vue new file mode 100644 index 0000000..9dd4ab6 --- /dev/null +++ b/frontend/src/pages/venue/SlotGrid.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/src/pages/venue/VenueHero.vue b/frontend/src/pages/venue/VenueHero.vue new file mode 100644 index 0000000..12aa46e --- /dev/null +++ b/frontend/src/pages/venue/VenueHero.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 3f1e073..a3be978 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -13,6 +13,11 @@ export const router = createRouter({ name: 'Venue', component: () => import('./pages/VenuePage.vue'), }, + { + path: '/v/:slug/book', + name: 'Book', + component: () => import('./pages/BookStub.vue'), + }, { path: '/manage', name: 'MyVenues', 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`. From b60c6a1db1543f9f8c2cb43f2525570b86f32c6c Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Tue, 7 Jul 2026 22:45:38 +0530 Subject: [PATCH 03/13] feat: implement Phase 3 booking, slot locking & mock payment End-to-end booking: select multiple slots, hold them together for 10 minutes, pay once through a mock gateway, get a Confirmed order that locks every slot. All-or-nothing on the slot_key unique index; lapsed holds are freed by a per-minute scheduler. - Booking.validate derives end_time + amount (pricing engine) + slot_key - BookingOrder.recompute_total / sync_status move order + lines together - hold_slots (validate, all-or-nothing, taken list), confirm_payment (guards), create_account_from_order, get_order - tasks.expire_holds (cron * * * * *) frees lapsed holds + fails payment - Checkout, MockGateway (10-min countdown), Confirmation pages + routes - 7 new tests (37 total green); agent-browser e2e green Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 39 ++++- apna_slot/api/public.py | 164 ++++++++++++++---- .../apna_slot/doctype/booking/booking.py | 43 +++++ .../doctype/booking_order/booking_order.json | 20 +++ .../doctype/booking_order/booking_order.py | 23 ++- apna_slot/hooks.py | 7 + apna_slot/tasks.py | 26 +++ apna_slot/tests/fixtures.py | 4 +- apna_slot/tests/test_booking_flow.py | 24 ++- apna_slot/tests/test_booking_lifecycle.py | 74 ++++++++ frontend/src/pages/BookStub.vue | 50 ------ frontend/src/pages/Checkout.vue | 157 +++++++++++++++++ frontend/src/pages/Confirmation.vue | 123 +++++++++++++ frontend/src/pages/checkout/MockGateway.vue | 65 +++++++ frontend/src/router.js | 7 +- 15 files changed, 729 insertions(+), 97 deletions(-) create mode 100644 apna_slot/tasks.py create mode 100644 apna_slot/tests/test_booking_lifecycle.py delete mode 100644 frontend/src/pages/BookStub.vue create mode 100644 frontend/src/pages/Checkout.vue create mode 100644 frontend/src/pages/Confirmation.vue create mode 100644 frontend/src/pages/checkout/MockGateway.vue diff --git a/PROGRESS.md b/PROGRESS.md index 1beb0d9..5b72fa7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,7 +12,7 @@ model / mechanisms are in [`specs/00-architecture.md`](specs/00-architecture.md) | **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) | ⬜ | +| **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 | @@ -48,14 +48,18 @@ Thinnest thread; validates the spine (slot-lock + server pricing) before widenin - [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 ⬜ +## 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) +- [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 ⬜ @@ -71,6 +75,25 @@ 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-07** — Phase 3 shipped. Booking Order gains `customer_email`/`customer`/`confirmed_on`. + `Booking.validate` now owns derivation (end_time from start + weekday slot duration; amount + recomputed via pricing engine; slot_key active/inactive from status). `BookingOrder.recompute_total` + + `sync_status(status, **fields)` move the order and every line together (re-saving each line rewrites + its slot_key → locks or frees). `hold_slots` validates venue Published + each slot legal + no dupes, + then all-or-nothing insert under a savepoint (unique index → rollback → `{ok:False, + reason:"slots_taken", taken:[…]}`); `confirm_payment` guards (own Pending payment, order Held, before + `hold_expires_at`); added `create_account_from_order` + `get_order`. `tasks.expire_holds` (cron + `* * * * *`) expires lapsed holds + marks payment Failed. Frontend: `Checkout` (editable cart + guest + form → hold), `MockGateway` (10-min countdown + "Pay ₹X (mock)"), `Confirmation` (summary + create- + account dialog); BookStub removed; routes `/v/:slug/book` + `/v/:slug/confirmed/:order`. 7 new tests + (37 total green); agent-browser e2e green (2-slot order → pay → both slots locked, third free; account + links customer). Deviations: (1) no `useCall` in this frappe-ui — kept `call`. (2) added `get_order` + guest API (not in spec) for the confirmation payload; hold returns `expires_in` seconds so the client + countdown avoids parsing naive server datetimes. (3) end_time/amount derivation moved out of the API + into `Booking.validate` (single source of truth). (4) `create_account_from_order` inherits Frappe + password-strength rules (weak passwords → 417). (5) built SPA is gitignored — run + `yarn --cwd frontend build`. + - **2026-07-07** — Phase 2 shipped. Full pricing engine (`compute_price` surcharge loop: day-type + time-band, additive); slot engine gains exceptions (Closed / Special Hours override) + midnight-crossing (`close <= open` → +24h, times wrap mod 24h) + past-slot filter; `get_public_venue` diff --git a/apna_slot/api/public.py b/apna_slot/api/public.py index ea4c933..77ff10b 100644 --- a/apna_slot/api/public.py +++ b/apna_slot/api/public.py @@ -7,7 +7,7 @@ import frappe from frappe import _ -from frappe.utils import add_to_date, now_datetime +from frappe.utils import add_to_date, get_datetime, now_datetime from apna_slot.engine.slots import get_slots_for @@ -72,31 +72,39 @@ def _assert_public_resource(resource): 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, } @@ -104,27 +112,117 @@ 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} +@frappe.whitelist(allow_guest=True) +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=["resource", "booking_date", "start_time", "end_time", "amount", "status"], + order_by="booking_date asc, start_time asc", + ) + for line in lines: + line["resource_name"] = frappe.db.get_value("Venue Resource", line.resource, "resource_name") + 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) @@ -132,35 +230,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/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..9f6fc96 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", 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/hooks.py b/apna_slot/hooks.py index d67c576..fe554f5 100644 --- a/apna_slot/hooks.py +++ b/apna_slot/hooks.py @@ -152,6 +152,13 @@ # 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" 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..e7a3761 100644 --- a/apna_slot/tests/fixtures.py +++ b/apna_slot/tests/fixtures.py @@ -46,8 +46,8 @@ 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): 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/frontend/src/pages/BookStub.vue b/frontend/src/pages/BookStub.vue deleted file mode 100644 index 744a667..0000000 --- a/frontend/src/pages/BookStub.vue +++ /dev/null @@ -1,50 +0,0 @@ - - - diff --git a/frontend/src/pages/Checkout.vue b/frontend/src/pages/Checkout.vue new file mode 100644 index 0000000..59cd24c --- /dev/null +++ b/frontend/src/pages/Checkout.vue @@ -0,0 +1,157 @@ + + + diff --git a/frontend/src/pages/Confirmation.vue b/frontend/src/pages/Confirmation.vue new file mode 100644 index 0000000..853457f --- /dev/null +++ b/frontend/src/pages/Confirmation.vue @@ -0,0 +1,123 @@ + + + diff --git a/frontend/src/pages/checkout/MockGateway.vue b/frontend/src/pages/checkout/MockGateway.vue new file mode 100644 index 0000000..50dd94c --- /dev/null +++ b/frontend/src/pages/checkout/MockGateway.vue @@ -0,0 +1,65 @@ + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index a3be978..65bc489 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -16,7 +16,12 @@ export const router = createRouter({ { path: '/v/:slug/book', name: 'Book', - component: () => import('./pages/BookStub.vue'), + component: () => import('./pages/Checkout.vue'), + }, + { + path: '/v/:slug/confirmed/:order', + name: 'Confirmed', + component: () => import('./pages/Confirmation.vue'), }, { path: '/manage', From 722c32a188ccdce2ee84b73411577b792ccffe23 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Wed, 8 Jul 2026 11:38:31 +0530 Subject: [PATCH 04/13] feat: implement Phase 4 notifications, reviews & dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the MVP with the three launch extras. Part A — Email confirmations: Booking Order on_update doc_event fires only on the real Held->Confirmed transition and enqueues one customer + one publisher email listing every slot; sending is best-effort so a mail outage never fails a paid booking. Part B — Reviews & ratings: new Venue Review DocType (Rating stored as a 0-1 fraction, booking unique), gated create_review (guest-allowed; Confirmed + owner + one-per-booking), and get_public_venue now returns avg_rating/review_count/ latest reviews. Frontend StarRating widget, venue-page review list, and a confirmation-page review form. Part C — Publisher dashboard: get_publisher_dashboard aggregates own-venue totals (today/week bookings, week/month revenue, upcoming, per-venue breakdown); Booking/Booking Order/Venue Review gain permission_query_conditions + has_permission scoping and Venue Publisher read perms; SPA /manage/dashboard and /manage/bookings (grouped-by-order + venue/status filters) with nav. 12 new tests (49 total green); agent-browser e2e verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 38 ++++- apna_slot/api/public.py | 85 +++++++++- apna_slot/api/publisher.py | 98 +++++++++++- .../apna_slot/doctype/booking/booking.json | 5 + .../doctype/booking_order/booking_order.json | 5 + .../doctype/venue_review/__init__.py | 0 .../doctype/venue_review/venue_review.json | 115 ++++++++++++++ .../doctype/venue_review/venue_review.py | 11 ++ apna_slot/hooks.py | 23 +-- apna_slot/notifications.py | 111 +++++++++++++ apna_slot/permissions.py | 47 +++++- apna_slot/tests/fixtures.py | 32 +++- apna_slot/tests/test_dashboard.py | 67 ++++++++ apna_slot/tests/test_notifications.py | 49 ++++++ apna_slot/tests/test_reviews.py | 53 +++++++ frontend/src/components/StarRating.vue | 33 ++++ frontend/src/pages/Bookings.vue | 148 ++++++++++++++++++ frontend/src/pages/Confirmation.vue | 8 + frontend/src/pages/Dashboard.vue | 88 +++++++++++ frontend/src/pages/ManageShell.vue | 27 +++- frontend/src/pages/VenuePage.vue | 3 + .../src/pages/confirmation/ReviewForm.vue | 71 +++++++++ frontend/src/pages/venue/ReviewList.vue | 28 ++++ frontend/src/router.js | 12 ++ ...phase-4-notifications-reviews-dashboard.md | 12 +- 25 files changed, 1131 insertions(+), 38 deletions(-) create mode 100644 apna_slot/apna_slot/doctype/venue_review/__init__.py create mode 100644 apna_slot/apna_slot/doctype/venue_review/venue_review.json create mode 100644 apna_slot/apna_slot/doctype/venue_review/venue_review.py create mode 100644 apna_slot/notifications.py create mode 100644 apna_slot/tests/test_dashboard.py create mode 100644 apna_slot/tests/test_notifications.py create mode 100644 apna_slot/tests/test_reviews.py create mode 100644 frontend/src/components/StarRating.vue create mode 100644 frontend/src/pages/Bookings.vue create mode 100644 frontend/src/pages/Dashboard.vue create mode 100644 frontend/src/pages/confirmation/ReviewForm.vue create mode 100644 frontend/src/pages/venue/ReviewList.vue diff --git a/PROGRESS.md b/PROGRESS.md index 5b72fa7..773fb3a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,7 +13,7 @@ model / mechanisms are in [`specs/00-architecture.md`](specs/00-architecture.md) | **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 | ⬜ | +| **4** | [Notifications, reviews & dashboard](specs/phase-4-notifications-reviews-dashboard.md) — MVP complete | ✅ | | **5** | [Roadmap / deferred](specs/phase-5-roadmap-deferred.md) | ⏸ post-MVP | --- @@ -61,13 +61,16 @@ Thinnest thread; validates the spine (slot-lock + server pricing) before widenin - [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 ⬜ +## 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** +- [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** --- @@ -75,6 +78,27 @@ 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 4 shipped → **MVP complete**. (A) `notifications.py`: Booking Order + `on_update` doc_event fires only on the real Held→Confirmed transition (`get_doc_before_save` + guard, never on insert), enqueues one customer + one publisher email listing every slot + (`build_confirmation_emails` is pure/testable); send is best-effort (mail outage never fails a + paid booking). (B) `Venue Review` DocType (Rating stored 0–1; `booking` unique); `create_review` + (guest-allowed, gated on Confirmed + owner + one-per-booking, stars→fraction); `get_public_venue` + now returns `avg_rating` (stars)/`review_count`/latest `reviews`; frontend `StarRating` widget + + `ReviewList` on the venue page + `ReviewForm` on the confirmation page. (C) + `get_publisher_dashboard` (own-venue-scoped totals: today/week bookings, week/month revenue via + qb `Sum`, upcoming list, per-venue breakdown); Booking/Booking Order/Venue Review get + `permission_query_conditions` + `has_permission` (shared `_venue_scoped_query`) and Venue + Publisher **read** perms; SPA `/manage/dashboard` (stat tiles + upcoming + breakdown) + + `/manage/bookings` (grouped-by-order, venue/status filters) + nav in `ManageShell`. 12 new tests + (49 total green); agent-browser e2e green (venue avg 4.5 over 2 reviews; confirmation review posts + once; dashboard + bookings scoped to the logged-in publisher; status filter re-queries). + Deviations: (1) dashboard/bookings live at **`/manage/*`** not `/app/*` (spec Part C said `/app`; + reconciled — `/app` is Frappe Desk); (2) revenue uses `frappe.qb` `Sum` — this frappe rejects + `"sum(x) as y"` string selects in `get_all`; (3) `get_order` lines now carry `name` + `reviewed` + so the confirmation form can post + hide reviewed slots; (4) rebuilt SPA is gitignored — run + `yarn --cwd frontend build`. + - **2026-07-07** — Phase 3 shipped. Booking Order gains `customer_email`/`customer`/`confirmed_on`. `Booking.validate` now owns derivation (end_time from start + weekday slot duration; amount recomputed via pricing engine; slot_key active/inactive from status). `BookingOrder.recompute_total` diff --git a/apna_slot/api/public.py b/apna_slot/api/public.py index 77ff10b..d59168f 100644 --- a/apna_slot/api/public.py +++ b/apna_slot/api/public.py @@ -35,6 +35,7 @@ def get_public_venue(slug: str): 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, @@ -48,11 +49,87 @@ def _public_venue_payload(venue): "base_price": venue.base_price, "currency": venue.currency, "gallery": [{"image": row.image, "caption": row.caption} for row in venue.gallery], - "avg_rating": 0, # Phase 4 - "review_count": 0, # Phase 4 + **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 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) @@ -132,11 +209,13 @@ def get_order(order: str): lines = frappe.get_all( "Booking", filters={"order": doc.name}, - fields=["resource", "booking_date", "start_time", "end_time", "amount", "status"], + 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, diff --git a/apna_slot/api/publisher.py b/apna_slot/api/publisher.py index 24526a3..b96a970 100644 --- a/apna_slot/api/publisher.py +++ b/apna_slot/api/publisher.py @@ -7,7 +7,8 @@ import frappe from frappe import _ -from frappe.utils import get_fullname +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" @@ -37,6 +38,101 @@ def become_publisher() -> dict: 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) 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_order/booking_order.json b/apna_slot/apna_slot/doctype/booking_order/booking_order.json index 9f6fc96..f73d3d3 100644 --- a/apna_slot/apna_slot/doctype/booking_order/booking_order.json +++ b/apna_slot/apna_slot/doctype/booking_order/booking_order.json @@ -108,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/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/hooks.py b/apna_slot/hooks.py index fe554f5..70c9c56 100644 --- a/apna_slot/hooks.py +++ b/apna_slot/hooks.py @@ -126,28 +126,31 @@ # ----------- # Permissions evaluated in scripted ways -# Scope publishers to their own venues + resources (System Managers unscoped). +# 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 # --------------- diff --git a/apna_slot/notifications.py b/apna_slot/notifications.py new file mode 100644 index 0000000..5eb435d --- /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/permissions.py b/apna_slot/permissions.py index a0c13e4..fe2afd7 100644 --- a/apna_slot/permissions.py +++ b/apna_slot/permissions.py @@ -1,4 +1,5 @@ -"""Row-level scoping: a publisher only ever sees their own venues and resources. +"""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. @@ -15,13 +16,31 @@ def venue_query(user: str | None = None) -> str: 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 ( - "`tabVenue Resource`.venue in " - f"(select name from `tabVenue` where publisher = {frappe.db.escape(user)})" - ) + 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): @@ -35,10 +54,26 @@ def has_venue_permission(doc, ptype=None, user=None): 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 frappe.db.get_value("Venue", doc.venue, "publisher") == user + return bool(venue) and frappe.db.get_value("Venue", venue, "publisher") == user def is_admin(user: str) -> bool: diff --git a/apna_slot/tests/fixtures.py b/apna_slot/tests/fixtures.py index e7a3761..f01c0e1 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", @@ -52,3 +61,24 @@ def customer(email=None): 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_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..01bceea --- /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 = {tuple(email["recipients"])[0] 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_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/frontend/src/components/StarRating.vue b/frontend/src/components/StarRating.vue new file mode 100644 index 0000000..32e7c64 --- /dev/null +++ b/frontend/src/components/StarRating.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/pages/Bookings.vue b/frontend/src/pages/Bookings.vue new file mode 100644 index 0000000..7247c15 --- /dev/null +++ b/frontend/src/pages/Bookings.vue @@ -0,0 +1,148 @@ + + + diff --git a/frontend/src/pages/Confirmation.vue b/frontend/src/pages/Confirmation.vue index 853457f..7551cef 100644 --- a/frontend/src/pages/Confirmation.vue +++ b/frontend/src/pages/Confirmation.vue @@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { call, Button, Dialog, FormControl, toast } from 'frappe-ui' import { money, clockTime } from '../format' +import ReviewForm from './confirmation/ReviewForm.vue' const route = useRoute() const router = useRouter() @@ -26,6 +27,11 @@ async function loadOrder() { } } +function markReviewed(booking) { + const line = order.value.lines.find((item) => item.name === booking) + if (line) line.reviewed = true +} + async function createAccount() { if (!password.value) return creating.value = true @@ -92,6 +98,8 @@ async function createAccount() {

    Account linked — log in with {{ order.customer_email }}.

    + +

    Order not found.

    diff --git a/frontend/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue new file mode 100644 index 0000000..7db658d --- /dev/null +++ b/frontend/src/pages/Dashboard.vue @@ -0,0 +1,88 @@ + + + diff --git a/frontend/src/pages/ManageShell.vue b/frontend/src/pages/ManageShell.vue index 8ff8ed1..fa86a45 100644 --- a/frontend/src/pages/ManageShell.vue +++ b/frontend/src/pages/ManageShell.vue @@ -1,15 +1,34 @@ diff --git a/frontend/src/pages/confirmation/ReviewForm.vue b/frontend/src/pages/confirmation/ReviewForm.vue new file mode 100644 index 0000000..fddab64 --- /dev/null +++ b/frontend/src/pages/confirmation/ReviewForm.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/pages/venue/ReviewList.vue b/frontend/src/pages/venue/ReviewList.vue new file mode 100644 index 0000000..88b1706 --- /dev/null +++ b/frontend/src/pages/venue/ReviewList.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 65bc489..403e51b 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -29,6 +29,18 @@ export const router = createRouter({ 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', 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 From 76201ef7003c4993ffbb2455c8be85ad86b3b2a3 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Wed, 8 Jul 2026 11:58:47 +0530 Subject: [PATCH 05/13] =?UTF-8?q?docs:=20sequence=20Phase=205=20post-MVP?= =?UTF-8?q?=20roadmap=20into=205.1=E2=80=935.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the deferred grab-bag into ordered tracer-bullet sub-phases, each with goal/backend/frontend/tests/acceptance. Order: marketplace, My Bookings, notification channels first (no payment work); real payments then unblocks cancellation/refunds and monetization; per-resource schedules and hardening last. Update PROGRESS + README. Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 22 ++- specs/README.md | 2 +- specs/phase-5-roadmap-deferred.md | 249 +++++++++++++++++++++++++----- 3 files changed, 232 insertions(+), 41 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 773fb3a..e293896 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -14,7 +14,20 @@ model / mechanisms are in [`specs/00-architecture.md`](specs/00-architecture.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 | +| **5** | [Post-MVP roadmap](specs/phase-5-roadmap-deferred.md) — sequenced into 5.1–5.8 | ⬜ ready | + +### 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 | ⬜ | --- @@ -78,6 +91,13 @@ 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 marked **ready for implementation**. Rewrote + `phase-5-roadmap-deferred.md`: the 8 deferred items are now sequenced into tracer-bullet + sub-phases **5.1–5.8**, each with goal / tracer bullet / backend / frontend / tests / + acceptance. Ordering: marketplace (5.1), My Bookings (5.2), notification channels (5.3) go + first (highest value, no payment work); real payments (5.4) then unblocks cancellation/refunds + (5.5) and monetization (5.6); per-resource schedules (5.7) and operational hardening (5.8) + last. No code yet — status ⬜ ready. - **2026-07-08** — Phase 4 shipped → **MVP complete**. (A) `notifications.py`: Booking Order `on_update` doc_event fires only on the real Held→Confirmed transition (`get_doc_before_save` guard, never on insert), enqueues one customer + one publisher email listing every slot 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-5-roadmap-deferred.md b/specs/phase-5-roadmap-deferred.md index 481de0e..b69bc65 100644 --- a/specs/phase-5-roadmap-deferred.md +++ b/specs/phase-5-roadmap-deferred.md @@ -1,56 +1,227 @@ -# 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 +**Goal.** A public homepage lists every Published venue with filters and search, so customers +discover venues beyond a shared link. -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. +**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. -## 5. Per-resource custom schedules +**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). -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. +**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. -## 6. Customer account area ("My Bookings") +**Tests.** Only Published venues returned; each filter narrows correctly; text search matches +name/city; pagination; a draft venue never appears. -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. +**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). -## 7. Additional notification channels +--- -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. +## 5.2 — Customer account area — "My Bookings" ⬜ -## 8. Operational hardening (as needed) +**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. -- 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. +**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. From b3795859da285104cde47080b6f6fede967a511c Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Wed, 8 Jul 2026 11:58:52 +0530 Subject: [PATCH 06/13] chore: add ralph autonomous-loop scripts ralph.sh feeds prompt.md to Claude Code in a loop (implement thinnest next slice), then runs qa_prompt.md to review the last commit into REVIEWS.md; stops on 'complete'. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/REVIEWS.md | 0 scripts/prompt.md | 6 ++++++ scripts/qa_prompt.md | 5 +++++ scripts/ralph.sh | 48 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 scripts/REVIEWS.md create mode 100644 scripts/prompt.md create mode 100644 scripts/qa_prompt.md create mode 100755 scripts/ralph.sh diff --git a/scripts/REVIEWS.md b/scripts/REVIEWS.md new file mode 100644 index 0000000..e69de29 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 ===" From fef8124f8b4f186f77a49ac99bf25ca59142a764 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Wed, 8 Jul 2026 12:03:34 +0530 Subject: [PATCH 07/13] feat: implement Phase 5.1 marketplace browse & search Public marketplace listing so customers discover venues beyond a shared link. - search_venues(city, q, start, page_length) + list_cities() (allow_guest, Published-only, card payload; text search over name/city; no publisher/draft leak) - home_page = "v" serves the SPA at the site root - Browse.vue (card grid + city filter + search) + browse/VenueCard.vue; router `/` - 4 new tests (53 total green); agent-browser e2e green Thinnest slice: city filter + text search live. category/date filters, pagination UI and a separate /search route deferred to the next widening. Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 17 ++++- apna_slot/api/public.py | 60 +++++++++++++++++ apna_slot/hooks.py | 3 +- apna_slot/tests/test_public_api.py | 34 +++++++++- frontend/src/pages/Browse.vue | 90 +++++++++++++++++++++++++ frontend/src/pages/browse/VenueCard.vue | 51 ++++++++++++++ frontend/src/router.js | 5 ++ specs/phase-5-roadmap-deferred.md | 15 ++++- 8 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 frontend/src/pages/Browse.vue create mode 100644 frontend/src/pages/browse/VenueCard.vue diff --git a/PROGRESS.md b/PROGRESS.md index e293896..60a08fc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -14,13 +14,13 @@ model / mechanisms are in [`specs/00-architecture.md`](specs/00-architecture.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 | ⬜ ready | +| **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.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 | ⬜ | @@ -91,6 +91,19 @@ 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 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 + `