From d4e84dee0e101899ab5e0579e259bcff05abec27 Mon Sep 17 00:00:00 2001 From: KesterJJ <62260424+camreeves@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:08:16 +0000 Subject: [PATCH] feat(place/vip_visitor_automation): add VIP visitor workflow --- drivers/place/vip_visitor_automation.cr | 197 +++++++++++++ drivers/place/vip_visitor_automation_spec.cr | 279 +++++++++++++++++++ drivers/place/vip_visitor_planner.cr | 122 ++++++++ drivers/place/vip_visitor_readme.md | 236 ++++++++++++++++ 4 files changed, 834 insertions(+) create mode 100644 drivers/place/vip_visitor_automation.cr create mode 100644 drivers/place/vip_visitor_automation_spec.cr create mode 100644 drivers/place/vip_visitor_planner.cr create mode 100644 drivers/place/vip_visitor_readme.md diff --git a/drivers/place/vip_visitor_automation.cr b/drivers/place/vip_visitor_automation.cr new file mode 100644 index 0000000000..814d5bdb58 --- /dev/null +++ b/drivers/place/vip_visitor_automation.cr @@ -0,0 +1,197 @@ +require "placeos-driver" +require "placeos-driver/interface/mailer" +require "placeos-driver/interface/mailer_templates" +require "placeos-models/placeos-models/base/jwt" +require "jwt" +require "uuid" +require "digest/sha256" +require "./vip_visitor_planner" + +# One instance per building. Planner is provided by Calendar_1. +class Place::VIPVisitorAutomation < PlaceOS::Driver + include Place::VIPVisitorPlanner + include PlaceOS::Driver::Interface::MailerTemplates + + descriptive_name "VIP Visitor Automation" + generic_name :VIPVisitorAutomation + description "Standalone VIP invitations, confirmation emails and Calendar Planner provisioning" + + accessor staff_api : StaffAPI_1 + accessor calendar : Calendar_1 + accessor mailer : Mailer_1 + + default_settings({ + building_id: "", + planner_group_id: "", + timezone: "Asia/Dubai", + confirmation_field: "vip_confirmed", + confirmation_url: "", + domain_uri: "", + jwt_private_key: PlaceOS::Model::JWTBase.private_key, + handler_emails: [] of String, + notification_emails: [] of String, + notify_arrival_departure: false, + location: "", + google_maps_url: "", + parking_details: "", + valet_details: "", + planner_task_assignments: {} of String => Array(String), + }) + + @lock = Mutex.new + @workflows = {} of String => Hash(String, JSON::Any) + + def on_load + @workflows = setting?(Hash(String, Hash(String, JSON::Any)), :workflow_state) || @workflows + monitor("staff/booking/changed") { |_, payload| booking_event(payload) } + monitor("staff/guest/attending") { |_, payload| guest_event(payload) } + monitor("staff/guest/checkin") { |_, payload| guest_event(payload) } + end + + protected def booking_event(payload : String) + event = JSON.parse(payload) + action = event["action"]?.try(&.as_s) || "changed" + if action == "checked_in" + action = event["checked_in"]? == true ? "arrived" : "departed" + end + process_booking(event["id"].as_i64, action) + rescue error + record_error(error) + end + + protected def guest_event(payload : String) + event = JSON.parse(payload) + return unless id = event["booking_id"]? + action = event["action"]?.try(&.as_s) || "changed" + action = event["checkin"]? == false ? "departed" : "arrived" if action == "checkin" + process_booking(id.as_i64, action) + rescue error + record_error(error) + end + + # Also provides an operator retry after a downstream failure. + @[Security(Level::Administrator)] + def process_booking(booking_id : Int64, action : String = "changed") + @lock.synchronize do + booking = staff_api.get_booking(booking_id).get + return false unless booking["booking_type"]? == "vip-visitor" + building = setting(String, :building_id).strip + raise "building_id must be configured" if building.empty? + return false unless booking["zones"]?.try(&.as_a.includes?(JSON::Any.new(building))) + ext = booking["extension_data"]?.try(&.as_h) || {} of String => JSON::Any + return false if booking["parent_id"]?.try { |id| !id.raw.nil? && !id.to_s.empty? } + return false if ext["parent_id"]?.try { |id| !id.raw.nil? && !id.to_s.empty? } + return false if booking["deleted"]? == true || booking["cancelled"]? == true || booking["rejected"]? == true || action.in?("cancelled", "deleted", "rejected") + state = @workflows[booking_id.to_s] ||= {} of String => JSON::Any + confirmed = ext[setting?(String, :confirmation_field) || "vip_confirmed"]? == true + signature = Digest::SHA256.hexdigest({ + booking["booking_start"]?, booking["booking_end"]?, booking["title"]?, booking["asset_id"]?, + ext["vip_assistant_email"]?, ext["vip_assistant_name"]?, + }.to_json) + unless confirmed || state["invitation_signature"]? == signature + send_invitation(booking, ext) + state["invited"] = JSON::Any.new(true) + state["invitation_signature"] = JSON::Any.new(signature) + checkpoint + end + if confirmed + unless state["confirmation_sent"]? == true + recipients = setting(Array(String), :handler_emails).reject(&.strip.empty?) + raise "handler_emails must be configured" if recipients.empty? + send_notice(recipients, "confirmed", booking, ext) + state["confirmation_sent"] = JSON::Any.new(true) + checkpoint + end + provision_plan(booking, state) { checkpoint } + end + if setting?(Bool, :notify_arrival_departure) && action.in?("arrived", "departed") && state[action]? != true + recipients = setting(Array(String), :notification_emails).reject(&.strip.empty?) + unless recipients.empty? + send_notice(recipients, action, booking, ext) + state[action] = JSON::Any.new(true) + checkpoint + end + end + self[:last_booking_id] = booking_id + self[:last_error] = nil + true + end + end + + def template_fields : Array(TemplateFields) + fields = %w(booking_id booking_title booking_user_name booking_user_email booking_booking_start booking_booking_end event_date event_time event_end_time location google_maps_url parking_details valet_details submitted_details confirmation_url visitor_email qr_code vip_title vip_full_name vip_mobile_number vip_assistant_name vip_assistant_email vip_assistant_mobile_number protocol_officer_name protocol_officer_mobile_number vehicle_plate_number vehicle_plate_type welcome_beverage welcome_beverage_notes).map do |name| + {name: name, description: name.gsub('_', ' ')} + end + %w(invitation confirmed arrived departed).map do |name| + TemplateFields.new(trigger: {"vip_visitor", name}, name: "VIP visitor #{name}", description: "VIP visitor #{name}", fields: fields) + end + end + + private def checkpoint + define_setting(:workflow_state, @workflows) + end + + private def record_error(error) + # Do not log payloads, guest JWTs or submitted personal details. + self[:last_error] = error.message + logger.warn { "VIP workflow failed: #{error.message}" } + end + + private def template_args(booking, ext) + args = Interface::Mailer::TemplateItems.new + ext.each { |key, value| args[key] = value.raw.is_a?(String) ? value.as_s : value.to_json } + %w(id title user_name user_email booking_start booking_end).each do |key| + args["booking_#{key}"] = booking[key]?.try(&.to_s) + end + location = Time::Location.load(setting?(String, :timezone) || "Asia/Dubai") + start = Time.unix(booking["booking_start"].as_i64).in(location) + args["event_date"] = start.to_s("%A, %-d %B %Y") + args["event_time"] = start.to_s("%H:%M %Z") + args["event_end_time"] = Time.unix(booking["booking_end"].as_i64).in(location).to_s("%H:%M %Z") + args["submitted_details"] = ext.to_json + %w(location google_maps_url parking_details valet_details).each { |key| args[key] = setting?(String, key) || "" } + args + end + + private def send_notice(to, template, booking, ext) + result = mailer.send_template(to, {"vip_visitor", template}, template_args(booking, ext)).get + raise "VIP #{template} email was not sent; check template configuration" if result.raw.nil? || result == false + end + + private def send_invitation(booking, ext) + assistant = ext["vip_assistant_email"]?.try(&.as_s).try(&.strip) + raise "vip_assistant_email is required" unless assistant && !assistant.empty? + url = URI.parse(setting(String, :confirmation_url)) + raise "confirmation_url must be an HTTPS URL" unless url.scheme == "https" && url.host + visitor_email = booking["asset_id"].as_s + id = booking["id"].to_s + building = setting(String, :building_id) + authority = URI.parse(setting(String, :domain_uri)) + raise "domain_uri must be an HTTPS authority URL" unless authority.scheme == "https" && authority.host + now = Time.utc.to_unix + expires = booking["booking_end"].as_i64 + raise "cannot invite an expired VIP booking" if expires <= now + # Restricted guest token. The confirmation endpoint must authorize this + # booking explicitly; never give external assistants public/write scope. + token = JWT.encode({ + iss: "POS", iat: now, exp: expires, jti: UUID.random.to_s, + aud: authority.host, + scope: ["guest", "metadata"], sub: visitor_email, + u: {n: ext["vip_assistant_name"]?.try(&.as_s) || assistant, e: visitor_email, p: 0, b: id, r: [id, building]}, + }, setting?(String, :jwt_private_key) || PlaceOS::Model::JWTBase.private_key, JWT::Algorithm::RS256) + fragment = url.fragment || "/" + path, _, query = fragment.partition('?') + params = URI::Params.parse(query) + params["uid"] = token + url.fragment = "#{path}?#{params}" + args = template_args(booking, ext) + args["confirmation_url"] = url.to_s + args["visitor_email"] = visitor_email + args["qr_code"] = "cid:vip-visitor-qr" + host = booking["user_email"]?.try(&.as_s) || "" + png = mailer.generate_png_qrcode(text: "VISIT:#{visitor_email},#{visitor_email},#{id},#{host}", size: 256).get.as_s + attachments = [{file_name: "vip-visitor.png", content: png, content_id: "vip-visitor-qr"}] + result = mailer.send_template(assistant, {"vip_visitor", "invitation"}, args, resource_attachments: attachments).get + raise "VIP invitation was not sent; check template configuration" if result.raw.nil? || result == false + end +end diff --git a/drivers/place/vip_visitor_automation_spec.cr b/drivers/place/vip_visitor_automation_spec.cr new file mode 100644 index 0000000000..34a2c12db2 --- /dev/null +++ b/drivers/place/vip_visitor_automation_spec.cr @@ -0,0 +1,279 @@ +require "placeos-driver/spec" +require "placeos-driver/interface/mailer" +require "base64" + +class VIPStaffMock < DriverSpecs::MockDriver + @start = (Time.utc + 1.day).to_unix + @pending_offset = 0_i64 + + def postpone_pending + @pending_offset = 3600_i64 + end + + def get_booking(booking_id : String | Int64, instance : Int64? = nil) + id = booking_id.to_i64 + start = @start + (id == 1 ? @pending_offset : 0_i64) + booking = JSON.parse({ + id: id, booking_type: "vip-visitor", booking_start: start, + booking_end: start + 3600, zones: ["zone-aecb"], title: "VIP Guest", + asset_id: "vip@example.net", user_email: "host@example.com", + booked_by_email: "booker@example.com", booked_by_name: "Booker", + extension_data: { + vip_assistant_email: "assistant@example.net", vip_full_name: "VIP Guest", + vip_confirmed: id != 1, + }, + }.to_json) + data = booking["extension_data"].as_h + case id + when 2 + data.merge!(JSON.parse({ + meet_greet: "External - Airport", tour: true, welcome_beverage: "Custom", + welcome_beverage_notes: "Tea", gift: true, photographer: true, + restaurant_reservation: {name: "Restaurant", address: "Dubai", time: @start}, + driver: "Third Party", welcome_screen: true, presentation: true, + }.to_json).as_h) + when 3 + booking.as_h["booking_type"] = JSON::Any.new("visitor") + when 4 + booking.as_h["zones"] = JSON.parse(["zone-other"].to_json) + when 5 + data["parent_id"] = JSON::Any.new("event-parent") + when 6 + booking.as_h["cancelled"] = JSON::Any.new(true) + when 7 + booking.as_h["deleted"] = JSON::Any.new(true) + when 8 + data.merge!(JSON.parse({meet_greet: "None", welcome_beverage: "none", driver: "", tour: false}.to_json).as_h) + when 9 + data["tour"] = JSON::Any.new(true) + data["gift"] = JSON::Any.new(true) + when 10 + data.merge!(JSON.parse({meet_greet: "internal", walkthrough: true, + welcome_beverage: "Standard", beverage_preference: "Custom", beverage_notes: "Coffee", + driver: "in_house", vip_mobile_number: "+971500000000", protocol_officer_name: "Officer"}.to_json).as_h) + when 11 + data.merge!(JSON.parse({meet_greet: "airport", driver: "third_party", + welcome_beverage: "Custom", beverage_preference: "None"}.to_json).as_h) + when 12 + booking.as_h["parent_id"] = JSON::Any.new(123_i64) + when 13 + booking.as_h["rejected"] = JSON::Any.new(true) + when 14 + data.merge!(JSON.parse({welcome_beverage: "Custom", welcome_beverage_custom: "Juice"}.to_json).as_h) + end + booking + end +end + +class VIPCalendarMock < DriverSpecs::MockDriver + @plans = [] of String + @buckets = [] of String + @tasks = [] of String + @failed_once = false + + def on_load + self[:plans] = @plans + self[:buckets] = @buckets + self[:tasks] = @tasks + end + + def create_plan(group_id : String, title : String) + raise "wrong group" unless group_id == "group-aecb" + @plans << title + self[:plans] = @plans + {id: "plan-#{@plans.size}"} + end + + def create_bucket(plan_id : String, name : String, order_hint : String? = nil) + @buckets << "#{plan_id}:#{name}" + self[:buckets] = @buckets + self[:order_hint] = order_hint + {id: "bucket-#{@buckets.size}", orderHint: "hint-#{@buckets.size}"} + end + + def create_task( + plan_id : String, + title : String, + bucket_id : String? = nil, + assigned_to_user_ids : Array(String)? = nil, + due_date_time : String? = nil, + start_date_time : String? = nil, + percent_complete : Int32? = nil, + priority : Int32? = nil, + order_hint : String? = nil, + ) + if plan_id == "plan-3" && title == "Giveaway Gift" && !@failed_once + @failed_once = true + raise "temporary Planner failure" + end + raise "tasks must start incomplete" unless percent_complete == 0 + raise "tasks require due time" unless due_date_time + if title.starts_with?("Meet & Greet") + raise "assignment was not forwarded" unless assigned_to_user_ids == ["user-greeter"] + end + @tasks << "#{plan_id}:#{bucket_id}:#{title}" + self[:tasks] = @tasks + {id: "task-#{@tasks.size}"} + end +end + +class VIPMailerMock < DriverSpecs::MockDriver + include PlaceOS::Driver::Interface::Mailer + @recipients = [] of String + + def on_load + self[:recipients] = @recipients + end + + def generate_png_qrcode(text : String, size : Int32 = 128) : String + "QR-#{text}" + end + + def send_template( + to : String | Array(String), + template : Tuple(String, String), + args : TemplateItems, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : String | Array(String) | Nil = nil, + reply_to : String | Array(String) | Nil = nil, + ) + @recipients.concat(to.is_a?(String) ? [to] : to) + self[:recipients] = @recipients + self[:last_args] = args + self[:last_template] = template + true + end + + def send_mail( + to : String | Array(String), + subject : String, + message_plaintext : String? = nil, + message_html : String? = nil, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : String | Array(String) | Nil = nil, + reply_to : String | Array(String) | Nil = nil, + ) : Bool + true + end +end + +DriverSpecs.mock_driver "Place::VIPVisitorAutomation" do + system({StaffAPI: {VIPStaffMock}, Calendar: {VIPCalendarMock}, Mailer: {VIPMailerMock}}) + settings({ + building_id: "zone-aecb", planner_group_id: "group-aecb", + confirmation_url: "https://example.com/vip-confirmation", + domain_uri: "https://example.com", handler_emails: ["handler@example.com"], + planner_task_assignments: {meet_greet: ["user-greeter"]}, + }) + + exec(:process_booking, 1).get + invitation = system(:Mailer)[:last_args] + uri = URI.parse(invitation["confirmation_url"].as_s) + token = URI::Params.parse(uri.fragment.not_nil!.partition('?')[2])["uid"] + encoded = token.split('.')[1].tr("-_", "+/") + encoded += "=" * ((4 - encoded.size % 4) % 4) + claims = JSON.parse(Base64.decode_string(encoded)) + claims["scope"].should eq ["guest", "metadata"] + claims["u"]["b"].should eq "1" + claims["u"]["r"].should eq ["1", "zone-aecb"] + claims["u"]["e"].should eq "vip@example.net" + claims["aud"].should eq "example.com" + system(:Mailer)[:recipients].should eq ["assistant@example.net"] + system(:Calendar)[:plans].should eq [] of String + exec(:process_booking, 1).get + system(:Mailer)[:recipients].should eq ["assistant@example.net"] + + exec(:process_booking, 2).get + system(:Calendar)[:plans].as_a.size.should eq 1 + system(:Calendar)[:buckets].should eq ["plan-1:To Do", "plan-1:In Progress", "plan-1:Done"] + system(:Calendar)[:order_hint].should eq "hint-2 !" + tasks = system(:Calendar)[:tasks].as_a.map(&.as_s) + tasks.size.should eq 9 + tasks.all?(&.starts_with?("plan-1:bucket-1:")).should be_true + tasks.any?(&.includes?("Meet & Greet - Airport")).should be_true + tasks.any?(&.includes?("Welcome Beverage - Custom: Tea")).should be_true + tasks.any?(&.includes?("Restaurant Reservation - Restaurant, Dubai")).should be_true + tasks.any?(&.includes?("Driver - Third Party")).should be_true + system(:Mailer)[:recipients].as_a.map(&.as_s).includes?("handler@example.com").should be_true + emails = system(:Mailer)[:recipients] + exec(:process_booking, 2).get + system(:Calendar)[:tasks].as_a.size.should eq 9 + system(:Mailer)[:recipients].should eq emails + + (3..7).each { |id| exec(:process_booking, id).get } + system(:Calendar)[:plans].as_a.size.should eq 1 + system(:Mailer)[:recipients].should eq emails + + exec(:process_booking, 8).get + system(:Calendar)[:plans].as_a.size.should eq 2 + system(:Calendar)[:buckets].as_a.size.should eq 6 + system(:Calendar)[:tasks].as_a.size.should eq 9 + + expect_raises(Exception) { exec(:process_booking, 9).get } + system(:Calendar)[:tasks].as_a.size.should eq 10 + exec(:process_booking, 9).get + system(:Calendar)[:plans].as_a.size.should eq 3 + system(:Calendar)[:buckets].as_a.size.should eq 9 + system(:Calendar)[:tasks].as_a.size.should eq 11 + + exec(:process_booking, 10).get + tasks = system(:Calendar)[:tasks].as_a.map(&.as_s) + tasks.any?(&.ends_with?("Meet & Greet - Internal")).should be_true + tasks.any?(&.ends_with?("Welcome Beverage - Custom: Coffee")).should be_true + tasks.any?(&.ends_with?("Driver - In-house")).should be_true + tasks.count(&.ends_with?("Walkthrough/Guided Tour")).should eq 3 + args = system(:Mailer)[:last_args] + args["vip_mobile_number"].should eq "+971500000000" + args["protocol_officer_name"].should eq "Officer" + JSON.parse(args["submitted_details"].as_s)["beverage_notes"].should eq "Coffee" + + exec(:process_booking, 11).get + tasks = system(:Calendar)[:tasks].as_a.map(&.as_s) + tasks.select(&.starts_with?("plan-5:")).size.should eq 2 + exec(:process_booking, 12).get + exec(:process_booking, 13).get + system(:Calendar)[:plans].as_a.size.should eq 5 + exec(:process_booking, 14).get + system(:Calendar)[:tasks].as_a.map(&.as_s).any?(&.ends_with?("Welcome Beverage - Custom: Juice")).should be_true + + # A metadata update triggers acceptance processing; the guest attending + # signal alone must never be treated as an arrival or as confirmation. + publish("staff/booking/changed", {id: 15, action: "metadata_changed"}.to_json) + sleep 0.5 + system(:Calendar)[:plans].as_a.size.should eq 7 + publish("staff/guest/attending", {booking_id: 1, action: "attending"}.to_json) + sleep 0.5 + system(:Calendar)[:plans].as_a.size.should eq 7 + + settings({ + building_id: "zone-aecb", planner_group_id: "group-aecb", + confirmation_url: "https://example.com/vip-confirmation", domain_uri: "https://example.com", + handler_emails: ["handler@example.com"], notify_arrival_departure: true, + notification_emails: ["manager@example.com"], + planner_task_assignments: {meet_greet: ["user-greeter"]}, + }) + publish("staff/booking/changed", {id: 15, action: "checked_in", checked_in: true}.to_json) + sleep 0.5 + publish("staff/guest/checkin", {booking_id: 15, action: "checkin", checkin: true}.to_json) + sleep 0.5 + system(:Mailer)[:recipients].as_a.count { |email| email == "manager@example.com" }.should eq 1 + publish("staff/guest/checkin", {booking_id: 15, action: "checkin", checkin: false}.to_json) + sleep 0.5 + publish("staff/booking/changed", {id: 15, action: "checked_in", checked_in: false}.to_json) + sleep 0.5 + system(:Mailer)[:recipients].as_a.count { |email| email == "manager@example.com" }.should eq 2 + + # Editing a pending visit refreshes its invitation, without accepting it. + system(:StaffAPI).as(VIPStaffMock).postpone_pending + exec(:process_booking, 1).get + system(:Mailer)[:recipients].as_a.count { |email| email == "assistant@example.net" }.should eq 2 + system(:Calendar)[:plans].as_a.size.should eq 7 + exec(:process_booking, 1).get + system(:Mailer)[:recipients].as_a.count { |email| email == "assistant@example.net" }.should eq 2 +end diff --git a/drivers/place/vip_visitor_planner.cr b/drivers/place/vip_visitor_planner.cr new file mode 100644 index 0000000000..971fe54826 --- /dev/null +++ b/drivers/place/vip_visitor_planner.cr @@ -0,0 +1,122 @@ +require "json" + +# Uses the Planner methods on Calendar_1 (Microsoft Graph calendar driver). +# The caller owns persistence and serializes work for each booking. +module Place::VIPVisitorPlanner + private def provision_plan(booking : JSON::Any, state : Hash(String, JSON::Any), &checkpoint : ->) + return if state["planner_complete"]?.try(&.as_bool?) == true + + group_id = setting(String, :planner_group_id).strip + raise "planner_group_id must identify an existing Microsoft 365 group" if group_id.empty? + tasks = vip_planner_tasks(booking) + assignments = setting?(Hash(String, Array(String)), :planner_task_assignments) || {} of String => Array(String) + + unless state["plan_id"]? + name = booking["extension_data"]?.try(&.["vip_full_name"]?).try(&.as_s?).presence || + booking["title"]?.try(&.as_s?).presence || "VIP Visit" + title = "#{name} - VIP Visit (#{booking["id"]})" + raise "Planner plan title exceeds 255 characters" if title.size > 255 + result = calendar.create_plan(group_id, title).get + state["plan_id"] = JSON::Any.new(vip_planner_id(result)) + yield + end + plan_id = state["plan_id"].as_s + + buckets = state["bucket_ids"]?.try(&.as_h) || {} of String => JSON::Any + state["bucket_ids"] = JSON::Any.new(buckets) + {"To Do", "In Progress", "Done"}.each do |name| + next if buckets[name]? + # Place each bucket after the preceding one, using the service's hint. + # https://learn.microsoft.com/en-us/graph/api/resources/planner-order-hint-format + order_hint = "#{state["bucket_order_hint"]?.try(&.as_s) || ""} !" + result = calendar.create_bucket(plan_id, name, order_hint).get + buckets[name] = JSON::Any.new(vip_planner_id(result)) + state["bucket_order_hint"] = result["orderHint"]? || JSON::Any.new(order_hint) + yield + end + + task_ids = state["task_ids"]?.try(&.as_h) || {} of String => JSON::Any + state["task_ids"] = JSON::Any.new(task_ids) + due_at = booking["booking_start"]?.try(&.as_i64?).try { |value| Time.unix(value).to_rfc3339 } + tasks.each do |key, title| + next if task_ids[key]? + result = calendar.create_task( + plan_id: plan_id, + title: title, + bucket_id: buckets["To Do"].as_s, + assigned_to_user_ids: assignments[key]?, + due_date_time: due_at, + percent_complete: 0, + ).get + task_ids[key] = JSON::Any.new(vip_planner_id(result)) + yield + end + state["planner_complete"] = JSON::Any.new(true) + yield + end + + private def vip_planner_id(result : JSON::Any) : String + id = result["id"]?.try(&.as_s?).presence + raise "Calendar returned no Planner resource ID" unless id + id + end + + private def vip_planner_tasks(booking : JSON::Any) : Hash(String, String) + data = booking["extension_data"]?.try(&.as_h?) || {} of String => JSON::Any + tasks = {} of String => String + if value = vip_planner_option(data, "meet_greet") + label = case value.downcase + when "internal" then "Internal" + when "airport", "external - airport", "external_airport" then "Airport" + else value + end + tasks["meet_greet"] = "Meet & Greet - #{label}" + end + { + "tour" => "Walkthrough/Guided Tour", + "gift" => "Giveaway Gift", + "photographer" => "Photographer", + "welcome_screen" => "Welcome screen", + "presentation" => "Presentation", + }.each do |key, title| + selected = key == "tour" ? (data[key]? || data["walkthrough"]?) : data[key]? + tasks[key] = title if selected.try(&.as_bool?) == true + end + beverage_key = data.has_key?("beverage_preference") ? "beverage_preference" : "welcome_beverage" + if value = vip_planner_option(data, beverage_key) + notes = data["beverage_notes"]?.try(&.as_s?).presence || + data["welcome_beverage_notes"]?.try(&.as_s?).presence || + data["welcome_beverage_custom"]?.try(&.as_s?).presence + tasks["welcome_beverage"] = "Welcome Beverage - #{value}#{notes ? ": #{notes}" : ""}" + end + if restaurant = data["restaurant_reservation"]?.try(&.as_h?) + name = restaurant["name"]?.try(&.as_s?).presence + timestamp = restaurant["time"]?.try(&.as_i64?) + raise "Restaurant reservation requires name and Unix time" unless name && timestamp + zone = Time::Location.load(setting?(String, :timezone) || "Asia/Dubai") + time = Time.unix(timestamp).in(zone).to_s("%Y-%m-%d %H:%M %z") + address = restaurant["address"]?.try(&.as_s?).presence + tasks["restaurant_reservation"] = "Restaurant Reservation - #{name}#{address ? ", #{address}" : ""} - #{time}" + end + if value = vip_planner_option(data, "driver") + label = case value.downcase + when "in_house", "in-house" then "In-house" + when "third_party", "third party" then "Third Party" + else value + end + tasks["driver"] = "Driver - #{label}" + end + tasks.each do |key, title| + # Calendar currently has no task-details API. Keep all requested details + # in the title and fail explicitly rather than silently dropping data. + raise "Planner task #{key} exceeds 255 characters" if title.size > 255 + end + tasks + end + + private def vip_planner_option(data : Hash(String, JSON::Any), key : String) : String? + value = data[key]?.try(&.as_s?).try(&.strip) + return nil if value.nil? || value.empty? || value.downcase == "none" + value + end +end diff --git a/drivers/place/vip_visitor_readme.md b/drivers/place/vip_visitor_readme.md new file mode 100644 index 0000000000..1f9be70ac8 --- /dev/null +++ b/drivers/place/vip_visitor_readme.md @@ -0,0 +1,236 @@ +# VIP visitor automation integration + +This workflow is for standalone Staff API bookings with `booking_type: "vip-visitor"`. +Bookings with a top-level `parent_id` or an `extension_data.parent_id` are excluded. +This also excludes children of group bookings; create individual standalone VIP +bookings for this implementation. Multiple-visitor group support is not included. +Planner operations use the existing Calendar driver, which provides `create_plan`, +`create_bucket`, and `create_task`. A separate Planner service driver is not required. +Creating a Planner plan in a configured Microsoft 365 group does not create a new +Microsoft Teams team or channel. Provision the intended group/Team separately. + +The `exclude_booking_types` option described below belongs to a companion +VisitorMailer change. It is not included in this new-driver-only change; ensure +that support is present before running both mail workflows for VIP bookings. + +## Configuration + +Add `VIPVisitorAutomation`, `StaffAPI_1`, and `Calendar_1` to the building's system. +Configure Template Mailer as `Mailer_1` and its downstream SMTP mailer as +`Mailer_2`. Configure one workflow instance per building. + +| Setting | Purpose | +| --- | --- | +| `building_id` | Required building zone ID; bookings outside it are ignored | +| `planner_group_id` | Existing Microsoft 365 group that owns the plans | +| `confirmation_url` | HTTPS address of the compatible confirmation application | +| `domain_uri` | PlaceOS authority URL used as JWT audience | +| `jwt_private_key` | Signing key trusted by the PlaceOS backend | +| `confirmation_field` | Boolean extension-data submission marker; defaults to `vip_confirmed` | +| `handler_emails` | Recipients of the completed form email | +| `timezone` | Email time zone, default `Asia/Dubai` | +| `location`, `google_maps_url`, `parking_details`, `valet_details` | Invitation template details | +| `notify_arrival_departure` | Enable optional arrival/departure emails; default false | +| `notification_emails` | Arrival/departure recipients, e.g. office/building managers | +| `planner_task_assignments` | Task-key to Microsoft user-ID array; unspecified tasks remain unassigned | + +Configure Template Mailer templates `vip_visitor.invitation`, +`vip_visitor.confirmed`, and, when enabled, `vip_visitor.arrived` and +`vip_visitor.departed`. Arguments include extension-data fields, `booking_id`, +`booking_title`, `booking_user_name`, `booking_user_email`, `booking_booking_start`, +`booking_booking_end`, `event_date`, `event_time`, `event_end_time`, and configured location details. +The invitation also provides `confirmation_url`, `visitor_email`, and an inline +QR image referenced through `qr_code`. + +`submitted_details` contains the extension data as JSON. Template Mailer performs +literal placeholder replacement, so use a plain-text completed-form template for +untrusted form fields rather than inserting raw values into HTML. Restrict template +recipients to the VIP handlers; template-level `to`, `cc`, and `bcc` settings can +override or extend the recipients configured on this driver. + +Before acceptance, changes to the booking time, title, visitor email, or assistant name/email send a replacement invitation. Repeated unchanged events do not resend. + +The confirmed template receives the submitted extension-data fields. Include all +requested form fields in that template to produce the completed-form email. + +The confirmation endpoint must save `extension_data.vip_confirmed: true` (or the +configured field) with the submitted details. An ordinary booking edit without +that marker must not create a plan. + +Workflow progress is checkpointed in the driver setting `workflow_state` after +successful downstream operations using the framework’s asynchronous `define_setting` mechanism. Keep this setting across restarts. An operator +can retry a failed workflow with administrator-only `process_booking(booking_id)`. +There is a crash window between a successful external operation and its saved +checkpoint, so this is not a transactional exactly-once guarantee across services. + +Example settings (replace placeholders; the confirmation endpoint must be ready +before enabling live invitations): + +```yaml +building_id: zone-aecb-building +planner_group_id: microsoft-365-group-object-id +timezone: Asia/Dubai +domain_uri: https://placeos.example.com +confirmation_url: https://placeos.example.com/enrolment/#/ +confirmation_field: vip_confirmed +handler_emails: + - vip-handlers@example.com +location: Rolex Tower +google_maps_url: https://maps.google.com/?q=Rolex+Tower+Dubai +parking_details: Use the visitor parking entrance. +valet_details: Contact reception on arrival. +notify_arrival_departure: false +``` + +Use the platform's configured signing key or set `jwt_private_key` securely; never +place a private key in a committed example configuration. The driver adds the JWT +as the fragment query parameter `uid` to the confirmation URL. + +If the ordinary Visitor Mailer is also active in this building, set this on that +driver to prevent normal invitation and notification emails for VIP bookings: + +```yaml +exclude_booking_types: + - vip-visitor +``` + +The exclusion defaults to an empty list and covers booking guest events, booking +changes, previous-host notifications, and booking reminders. Calendar-event +visitors remain unaffected. + +## Deployment checks + +- Implement and test the restricted confirmation endpoint and the form's explicit + acceptance marker; the existing generic booking PATCH route is insufficient. +- Configure the existing Microsoft 365 group, Calendar's Planner access, signing + key and authority domain; a new Teams team/channel is not created. +- Configure invitation and completed-form templates, QR image, location, map, + parking/valet information, and handler recipients. Check template overrides. +- Exclude VIP bookings from the ordinary Visitor Mailer when both drivers run. +- Verify invitation, submission email, selected tasks, retry after failure, and + duplicate-event suppression in staging with test recipients. +- Enable optional arrival/departure notifications only after confirming recipients. + +These are deployment instructions; development and tests do not send live emails +or create live Microsoft Planner resources. + +## Staff API events + +The locally inspected Staff API publishes `staff/booking/changed` for booking +creation and updates. The payload includes `id`, `booking_type`, `zones`, +`booking_start`, `booking_end`, `user_email`, `booked_by_email`, and `extension_data`. + +| Action | Meaning | +| --- | --- | +| `create` | New booking | +| `metadata_changed` | Ordinary booking PATCH without a time/resource change | +| `changed` | Booking PATCH that resets booking state | +| `extdata_changed` | PATCH `/bookings/:id/ext_data?signal_changes=true` | +| `checked_in` | Check-in **or check-out**; inspect `checked_in` | + +The extension-data-only endpoint defaults `signal_changes` to false. Use true +when the form submission needs to trigger automation. + +`staff/guest/attending` is emitted when an attendee is added, including initial +booking creation. It is not a confirmation-form-submitted event, and does not +contain `booking_type` or extension data. The sequence diagram's claim that a +metadata PATCH produces that event does not match the inspected implementation. + +`staff/guest/checkin` carries `booking_id` and `checkin: true/false`. Arrival and +departure must remain separate from acceptance of an invitation. + +## External confirmation app integration gap + +The AECB UI implementation inspected in `aecb-catering-host-backport` has the +enrolment route `/enrolment/#/?uid=`. Its token parser expects `u.r` and selects +VIP mode using `u.b` or a top-level `booking_id`. Its confirmation form PATCHes the +booking extension data, but currently writes no explicit acceptance marker. +The separate check-in button invokes the booking check-in endpoint. + +Before connecting that external form, implement a scoped backend confirmation +endpoint and update the form to use it. The endpoint must verify the token's +signature, expiry, audience, tenant and exact booking binding; permit only the +confirmation fields; and persist an explicit submission marker with the form +data before publishing the booking-changed event. Treat that marker as acceptance, +not the presence of optional phone/name/car fields or an arbitrary booking edit. + +The existing guest JWT scopes (`guest`, `metadata`) cannot read or PATCH the +general `/api/staff/v1/bookings/:id` endpoints in the inspected backend. Those +endpoints require public API scope and writes additionally require the booking +owner or a zone manager. Adding a booking claim to a JWT does not itself grant +access. Do not issue a broadly privileged staff token to the external assistant. + +The guest profile PATCH endpoint updates the guest record, not booking metadata. +Guest catering endpoints support a limited beverage selection, not arbitrary VIP +form submission. Neither is a substitute for the required confirmation endpoint. + +## Field compatibility + +The implementation document and the existing AECB UI use some different keys: + +| Document key/value | Existing AECB UI key/value | +| --- | --- | +| `tour` | `walkthrough` | +| `vip_mobile_number` | `vip_contact_number` | +| `vip_assistant_name` (submitted) | `assistant_name` | +| `vip_assistant_mobile_number` | `assistant_contact_number` | +| `protocol_officer_mobile_number` | `protocol_officer_contact_number` | +| `welcome_beverage` (submitted) | `beverage_preference` | +| Beverage notes | `beverage_notes` (submitted), `welcome_beverage_custom` (booked) | +| `In-house`, `Third Party` | `in_house`, `third_party` | + +The UI uses lower-case `none`, `standard`, `custom` beverage values. Prefer the +submitted beverage choice over the original booking choice when both are present. +Keep all submitted fields available to the handler email template. + +## Scope of source documents + +Both local PDFs were read, and the journey diagram was rendered and inspected. +The wider journey diagram includes reminders, WhatsApp messages, catering, +access control, welcome screens, and thank-you messages. Those are separate +automations from the core invitation, submission email, and Planner workflow. +The diagram's phrase “Space on Teams” does not add a Teams-creation operation to +the Calendar driver's Planner methods. + +## Planner permissions + +The Calendar connection needs Planner write permissions. Microsoft documents +`Tasks.ReadWrite` for delegated work/school accounts and `Tasks.ReadWrite.All` +for application access for [creating plans](https://learn.microsoft.com/en-us/graph/api/planner-post-plans?view=graph-rest-1.0). +Confirm tenant consent and access to the configured group before deployment. +Tasks are created from the confirmed booking snapshot; later edits do not rewrite +an already completed Planner workflow. No existing template plan is cloned: +the three buckets and conditional tasks are defined by this driver. + +## Minimal template bodies + +For `vip_visitor.invitation`, a plain-text starting point is: + +```text +VIP visit: %{booking_title} +%{event_date}, %{event_time} – %{event_end_time} +Location: %{location} +Google Maps: %{google_maps_url} +Parking: %{parking_details} +Valet: %{valet_details} +Please complete the visit details: %{confirmation_url} +``` + +In the HTML invitation, reference the attached QR image with +`Visitor QR code`. +For `vip_visitor.confirmed`, include `%{submitted_details}` in the plain-text body +to return every submitted field to the handlers. These are template configuration +examples; the driver does not create or send tenant templates during installation. + +## Validation + +The VIP automation driver/spec compiled and passed the driver harness, covering +conditional tasks, submitted field aliases, invitation refresh and duplicate +suppression, explicit acceptance, partial-failure resume, and lifecycle events. +The focused VisitorMailer exclusion regression passed with the complete spec +restored afterwards. Formatting and diff checks passed. + +The complete existing VisitorMailer suite did not pass in this environment: +separate runs failed in existing system-lookup retry and debounce timing +assertions before reaching the new exclusion checks. No production mail or +Microsoft Graph resources were used in these tests.