guesty-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE +21 -0
- data/README.md +178 -0
- data/lib/guesty/access_tokens/upsert.rb +61 -0
- data/lib/guesty/api_error.rb +73 -0
- data/lib/guesty/client.rb +138 -0
- data/lib/guesty/config.rb +29 -0
- data/lib/guesty/conversations/attachments/index.rb +42 -0
- data/lib/guesty/conversations/index.rb +58 -0
- data/lib/guesty/conversations/messages/reply.rb +55 -0
- data/lib/guesty/conversations/posts/index.rb +121 -0
- data/lib/guesty/conversations/posts/since.rb +37 -0
- data/lib/guesty/conversations/search.rb +31 -0
- data/lib/guesty/conversations/show.rb +25 -0
- data/lib/guesty/conversations.rb +15 -0
- data/lib/guesty/error.rb +32 -0
- data/lib/guesty/guest_folio/invoice_items/index.rb +61 -0
- data/lib/guesty/guests/profiles/create.rb +25 -0
- data/lib/guesty/guests/profiles/show.rb +23 -0
- data/lib/guesty/guests/profiles/update.rb +36 -0
- data/lib/guesty/guests.rb +5 -0
- data/lib/guesty/homes/calendars/show.rb +134 -0
- data/lib/guesty/homes/search.rb +46 -0
- data/lib/guesty/memory_rate_limiter.rb +28 -0
- data/lib/guesty/memory_token_store.rb +33 -0
- data/lib/guesty/operation.rb +37 -0
- data/lib/guesty/rate_limiter_middleware.rb +23 -0
- data/lib/guesty/reservations/accommodation_fare/update.rb +44 -0
- data/lib/guesty/reservations/approve.rb +23 -0
- data/lib/guesty/reservations/create.rb +50 -0
- data/lib/guesty/reservations/custom_fields/update.rb +26 -0
- data/lib/guesty/reservations/dates/update.rb +41 -0
- data/lib/guesty/reservations/decline.rb +30 -0
- data/lib/guesty/reservations/flags/update.rb +33 -0
- data/lib/guesty/reservations/payments/create.rb +72 -0
- data/lib/guesty/reservations/pre_approve.rb +23 -0
- data/lib/guesty/reservations/recover.rb +27 -0
- data/lib/guesty/reservations/reviews/extractors/airbnb.rb +66 -0
- data/lib/guesty/reservations/reviews/extractors/base.rb +36 -0
- data/lib/guesty/reservations/reviews/extractors/booking_com.rb +35 -0
- data/lib/guesty/reservations/reviews/extractors/vrbo.rb +46 -0
- data/lib/guesty/reservations/reviews/index.rb +67 -0
- data/lib/guesty/reservations/search.rb +63 -0
- data/lib/guesty/reservations/show.rb +28 -0
- data/lib/guesty/reservations/status/cancel.rb +34 -0
- data/lib/guesty/reservations/status/update.rb +21 -0
- data/lib/guesty/reservations/stays/update.rb +42 -0
- data/lib/guesty/reservations/update.rb +19 -0
- data/lib/guesty/reservations.rb +13 -0
- data/lib/guesty/reviews/respond.rb +19 -0
- data/lib/guesty/version.rb +3 -0
- data/lib/guesty/views/create.rb +19 -0
- data/lib/guesty/views/update.rb +33 -0
- data/lib/guesty/webhooks/create.rb +21 -0
- data/lib/guesty/webhooks/index.rb +18 -0
- data/lib/guesty/webhooks/signatures/verify.rb +76 -0
- data/lib/guesty/webhooks/sync.rb +36 -0
- data/lib/guesty/webhooks/update.rb +22 -0
- data/lib/guesty-client.rb +1 -0
- data/lib/guesty.rb +78 -0
- data/sig/guesty.rbs +427 -0
- metadata +191 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Conversations
|
|
3
|
+
module Messages
|
|
4
|
+
class Reply
|
|
5
|
+
include Guesty::Operation
|
|
6
|
+
|
|
7
|
+
# Guesty module type per booking channel; direct channels go by email.
|
|
8
|
+
BOOKING_MODULE_TYPES = {
|
|
9
|
+
'airbnb' => 'airbnb2',
|
|
10
|
+
'booking_com' => 'bookingCom',
|
|
11
|
+
'guestit' => 'email',
|
|
12
|
+
'core_manual' => 'email',
|
|
13
|
+
'core_group_manual' => 'email',
|
|
14
|
+
'ago' => 'email',
|
|
15
|
+
'owner_stay' => 'email'
|
|
16
|
+
}.freeze #: ::Hash[::String, ::String]
|
|
17
|
+
|
|
18
|
+
# @rbs (::String? channel_name) -> ::String?
|
|
19
|
+
def self.module_type_for(channel_name)
|
|
20
|
+
BOOKING_MODULE_TYPES[channel_name]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs (conversation_id: String, body: String, ?module_type: String?) -> Dry::Monads::Result[Hash[Symbol, untyped]]
|
|
24
|
+
def call(conversation_id:, body:, module_type: nil)
|
|
25
|
+
argument! conversation_id: :required, fatal: true if conversation_id.blank?
|
|
26
|
+
argument! body: :required, fatal: true if body.blank?
|
|
27
|
+
argument! module_type: :invalid, fatal: true if !module_type.nil? && module_type.blank?
|
|
28
|
+
|
|
29
|
+
# On `platform` Guesty picks the booking channel, or email, and echoes the channel it used.
|
|
30
|
+
response = Guesty.do_request(
|
|
31
|
+
:post,
|
|
32
|
+
"communication/conversations/#{conversation_id}/send-message",
|
|
33
|
+
{ module: { type: module_type || 'platform' }, body: },
|
|
34
|
+
retries: 0
|
|
35
|
+
)
|
|
36
|
+
return response unless response.success?
|
|
37
|
+
|
|
38
|
+
Success(normalize(response.value!.body.fetch(:data)))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def normalize(post)
|
|
44
|
+
{
|
|
45
|
+
message_id: post.fetch(:_id),
|
|
46
|
+
conversation_id: post.fetch(:conversationId),
|
|
47
|
+
module_type: post.dig(:module, :type),
|
|
48
|
+
body: post.fetch(:body),
|
|
49
|
+
created_at: Time.iso8601(post.fetch(:createdAt))
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Conversations
|
|
3
|
+
module Posts
|
|
4
|
+
class Index
|
|
5
|
+
include Guesty::Operation
|
|
6
|
+
|
|
7
|
+
LIMIT = 500
|
|
8
|
+
LOG_SENDER = 'log'.freeze #: ::String
|
|
9
|
+
SENDERS = ['guest', 'host', LOG_SENDER].freeze #: ::Array[::String]
|
|
10
|
+
SYSTEM_MODULE_TYPES = %w[note aiSummary].freeze #: ::Array[::String]
|
|
11
|
+
INVALID_RESPONSE = 'Guesty posts response has an unexpected shape.'.freeze #: ::String
|
|
12
|
+
|
|
13
|
+
# @rbs (conversation_id: ::String, ?cursor: ::String?) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
14
|
+
def call(conversation_id:, cursor: nil)
|
|
15
|
+
argument! conversation_id: :required if conversation_id.blank?
|
|
16
|
+
|
|
17
|
+
response = Guesty.do_request(
|
|
18
|
+
:get,
|
|
19
|
+
"communication/conversations/#{conversation_id}/posts",
|
|
20
|
+
query(cursor)
|
|
21
|
+
)
|
|
22
|
+
return response unless response.success?
|
|
23
|
+
|
|
24
|
+
Success(normalize_response(response.value!.body[:data]))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# @rbs (untyped data) -> ::Hash[::Symbol, untyped]
|
|
30
|
+
def normalize_response(data)
|
|
31
|
+
raw_posts = data.is_a?(Hash) ? data[:posts] : nil #: ::Array[untyped]?
|
|
32
|
+
error! INVALID_RESPONSE unless raw_posts.is_a?(Array)
|
|
33
|
+
|
|
34
|
+
posts = raw_posts.filter_map { |post| normalize_post(post) }
|
|
35
|
+
{ results: posts, cursor_after: data.dig(:cursor, :after).presence, oldest_created_at: oldest_created_at(raw_posts) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Taken before the filter. A page of only skipped post types must still tell the caller
|
|
39
|
+
# how far back it reached.
|
|
40
|
+
# @rbs (::Array[untyped] raw_posts) -> ::Time?
|
|
41
|
+
def oldest_created_at(raw_posts)
|
|
42
|
+
raw_posts.filter_map { |post| Time.iso8601(post[:createdAt]) if post[:createdAt].present? }.min
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def query(cursor)
|
|
46
|
+
params = { limit: LIMIT, sort: '-createdAt' }
|
|
47
|
+
params[:cursorAfter] = cursor if cursor.present?
|
|
48
|
+
params
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def normalize_attachments(attachments)
|
|
52
|
+
attachments.map { |attachment| attachment_media_type(attachment[:type]) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Parallel to #normalize_attachments. Index N names the same attachment in both arrays,
|
|
56
|
+
# so a missing attachmentUrl keeps its slot as an empty string.
|
|
57
|
+
def normalize_attachment_keys(attachments)
|
|
58
|
+
attachments.map { |attachment| attachment[:attachmentUrl].to_s }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def attachment_media_type(type)
|
|
62
|
+
return 'application/octet-stream' unless type.is_a?(String) && type.present?
|
|
63
|
+
return type if type.include?('/')
|
|
64
|
+
|
|
65
|
+
MiniMime.lookup_by_extension(type)&.content_type || 'application/octet-stream'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Guesty allows a post that is only an attachment. A post with neither text nor a file
|
|
69
|
+
# carries nothing. Provider-side automation posts stay visible so the host does not answer a
|
|
70
|
+
# turn that was already answered.
|
|
71
|
+
def importable?(post, attachments)
|
|
72
|
+
return false if post[:_id].blank? || post[:createdAt].blank?
|
|
73
|
+
return false if SYSTEM_MODULE_TYPES.include?(post.dig(:module, :type))
|
|
74
|
+
|
|
75
|
+
SENDERS.include?(post[:sentBy]) && (post[:body].present? || attachments.any?)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# provider_reservation_id routes the post to its stay and author_name lands on the sync row; neither is a message column.
|
|
79
|
+
# @rbs (untyped post) -> untyped
|
|
80
|
+
def normalize_post(post)
|
|
81
|
+
raw_attachments = post.fetch(:attachments, [])
|
|
82
|
+
attachments = normalize_attachments(raw_attachments)
|
|
83
|
+
return unless importable?(post, attachments)
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
provider_message_id: post[:_id],
|
|
87
|
+
sender_kind: sender_kind(post),
|
|
88
|
+
body: post[:body] || '',
|
|
89
|
+
attachments:,
|
|
90
|
+
attachment_keys: normalize_attachment_keys(raw_attachments),
|
|
91
|
+
module_type: channel_type(post),
|
|
92
|
+
provider_reservation_id: post.dig(:module, :reservationId),
|
|
93
|
+
provider_occurred_at: Time.iso8601(post[:createdAt]),
|
|
94
|
+
author_name: author_name(post)
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Guesty's own scheduled posts and reservation logs are system posts. Everything else keeps the sender
|
|
99
|
+
# Guesty names.
|
|
100
|
+
# @rbs (untyped post) -> String
|
|
101
|
+
def sender_kind(post)
|
|
102
|
+
post[:isAutomatic] || post[:sentBy] == LOG_SENDER ? 'system' : post[:sentBy]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @rbs (untyped post) -> String?
|
|
106
|
+
def channel_type(post)
|
|
107
|
+
post.dig(:module, :type) unless post[:sentBy] == LOG_SENDER
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Only a person answering from Guesty has a name worth keeping.
|
|
111
|
+
# @rbs (untyped post) -> String?
|
|
112
|
+
def author_name(post)
|
|
113
|
+
return if post[:isAutomatic] || post[:sentBy] != 'host'
|
|
114
|
+
|
|
115
|
+
name = post.dig(:from, :fullName)
|
|
116
|
+
name.presence if name.is_a?(String)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Conversations
|
|
3
|
+
module Posts
|
|
4
|
+
# Walks a thread newest-first until the page that reaches `since`, or to the end without one.
|
|
5
|
+
class Since
|
|
6
|
+
include Guesty::Operation
|
|
7
|
+
|
|
8
|
+
MAX_PAGES = 20
|
|
9
|
+
|
|
10
|
+
# @rbs (conversation_id: ::String, ?since: ::Time?) -> Dry::Monads::Result[::Array[untyped]]
|
|
11
|
+
def call(conversation_id:, since: nil)
|
|
12
|
+
argument! conversation_id: :required if conversation_id.blank?
|
|
13
|
+
|
|
14
|
+
posts = [] #: ::Array[untyped]
|
|
15
|
+
cursor = nil #: ::String?
|
|
16
|
+
|
|
17
|
+
MAX_PAGES.times do
|
|
18
|
+
page = Index.new.call!(conversation_id:, cursor:).value!
|
|
19
|
+
posts.concat(page.fetch(:results))
|
|
20
|
+
|
|
21
|
+
cursor = page.fetch(:cursor_after)
|
|
22
|
+
return Success(posts) if cursor.nil? || reached?(since, page.fetch(:oldest_created_at))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
error! "Guesty conversation #{conversation_id} pagination exceeded #{MAX_PAGES} pages", fatal: true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# @rbs (::Time? since, ::Time? oldest_created_at) -> bool
|
|
31
|
+
def reached?(since, oldest_created_at)
|
|
32
|
+
since.present? && oldest_created_at.present? && oldest_created_at <= since
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Conversations
|
|
3
|
+
class Search
|
|
4
|
+
include Guesty::Operation
|
|
5
|
+
|
|
6
|
+
FIELDS = '_id meta.reservations._id meta.reservations.status'.freeze #: ::String
|
|
7
|
+
|
|
8
|
+
# @rbs (external_id: ::String) -> Dry::Monads::Result[::Hash[::Symbol, untyped]?]
|
|
9
|
+
def call(external_id:)
|
|
10
|
+
argument! external_id: :required if external_id.blank?
|
|
11
|
+
|
|
12
|
+
response = Guesty.do_request(:get, 'communication/conversations', query(external_id))
|
|
13
|
+
return response unless response.success?
|
|
14
|
+
|
|
15
|
+
conversation = response.value!.body[:data][:conversations].first
|
|
16
|
+
|
|
17
|
+
Success(conversation && { id: conversation.fetch(:_id), reservations: Guesty::Reservations.from_conversation(conversation) })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def query(external_id)
|
|
23
|
+
{
|
|
24
|
+
fields: FIELDS,
|
|
25
|
+
filters: [{ field: 'reservation._id', operator: '$eq', value: external_id }].to_json,
|
|
26
|
+
limit: 1
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Conversations
|
|
3
|
+
class Show
|
|
4
|
+
include Guesty::Operation
|
|
5
|
+
|
|
6
|
+
FIELDS = Search::FIELDS #: ::String
|
|
7
|
+
|
|
8
|
+
# @rbs (conversation_id: ::String) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
9
|
+
def call(conversation_id:)
|
|
10
|
+
argument! conversation_id: :required if conversation_id.blank?
|
|
11
|
+
|
|
12
|
+
response = Guesty.do_request(
|
|
13
|
+
:get,
|
|
14
|
+
"communication/conversations/#{conversation_id}",
|
|
15
|
+
{ fields: FIELDS }
|
|
16
|
+
)
|
|
17
|
+
return response unless response.success?
|
|
18
|
+
|
|
19
|
+
conversation = response.value!.body[:data]
|
|
20
|
+
|
|
21
|
+
Success({ id: conversation.fetch(:_id), reservations: Guesty::Reservations.from_conversation(conversation) })
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Conversations
|
|
3
|
+
# Neutral channel names for Guesty's versioned module types.
|
|
4
|
+
CHANNELS = {
|
|
5
|
+
'airbnb2' => 'airbnb',
|
|
6
|
+
'bookingCom' => 'booking_com'
|
|
7
|
+
}.freeze #: ::Hash[::String, ::String]
|
|
8
|
+
VERSION_SUFFIX = /\d+\z/ #: ::Regexp
|
|
9
|
+
|
|
10
|
+
# @rbs (::String module_type) -> ::String
|
|
11
|
+
def self.channel(module_type)
|
|
12
|
+
CHANNELS.fetch(module_type) { module_type.sub(VERSION_SUFFIX, '') }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/guesty/error.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
attr_reader :fatal
|
|
4
|
+
|
|
5
|
+
# @rbs (?::String message, ?fatal: bool) -> void
|
|
6
|
+
def initialize(message = nil, fatal: false)
|
|
7
|
+
@fatal = fatal
|
|
8
|
+
|
|
9
|
+
super(message)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Raised by operations before any network call when the arguments are unusable.
|
|
14
|
+
# fatal: true marks a refusal no retry can fix; the host decides what to do with it.
|
|
15
|
+
class ArgumentError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised by the rate limiter middleware when the injected limiter refuses the
|
|
18
|
+
# request: the call never left the process, so no write landed upstream.
|
|
19
|
+
class ThrottledError < Error
|
|
20
|
+
attr_reader :key, :threshold, :interval, :retry_after
|
|
21
|
+
|
|
22
|
+
# @rbs (key: ::Symbol, threshold: ::Integer, interval: untyped, retry_after: untyped) -> void
|
|
23
|
+
def initialize(key:, threshold:, interval:, retry_after:)
|
|
24
|
+
@key = key
|
|
25
|
+
@threshold = threshold
|
|
26
|
+
@interval = interval
|
|
27
|
+
@retry_after = retry_after
|
|
28
|
+
|
|
29
|
+
super("Rate limit exceeded for #{key}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module GuestFolio
|
|
3
|
+
module InvoiceItems
|
|
4
|
+
# Closed Airbnb Resolution Center items: negative amounts are refunds and
|
|
5
|
+
# positive amounts are guest charges.
|
|
6
|
+
class Index
|
|
7
|
+
include Guesty::Operation
|
|
8
|
+
|
|
9
|
+
FIELDS = %w[normalType totalPrice logs.createdAt].freeze #: ::Array[::String]
|
|
10
|
+
INVALID_RESPONSE = 'Guesty invoice items response has an unexpected shape.'.freeze #: ::String
|
|
11
|
+
|
|
12
|
+
# @rbs (reservation_id: ::String) -> Dry::Monads::Result[::Array[untyped]]
|
|
13
|
+
def call(reservation_id:)
|
|
14
|
+
argument! reservation_id: :required if reservation_id.blank?
|
|
15
|
+
|
|
16
|
+
response = Guesty.do_request(:get, 'guest-folio/invoice-items', query(reservation_id))
|
|
17
|
+
return response unless response.success?
|
|
18
|
+
|
|
19
|
+
Success(normalize_response(response.value!.body, reservation_id))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# @rbs (::String reservation_id) -> ::Hash[::Symbol, ::String]
|
|
25
|
+
def query(reservation_id)
|
|
26
|
+
{
|
|
27
|
+
reservationIds: reservation_id,
|
|
28
|
+
fields: FIELDS.join(',')
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @rbs (untyped body, ::String reservation_id) -> ::Array[untyped]
|
|
33
|
+
def normalize_response(body, reservation_id)
|
|
34
|
+
results = body[:results] if body.is_a?(Hash)
|
|
35
|
+
error! INVALID_RESPONSE unless results.is_a?(Array)
|
|
36
|
+
|
|
37
|
+
result = results.find { |row| row[:reservationId] == reservation_id }
|
|
38
|
+
return [] if result.nil?
|
|
39
|
+
|
|
40
|
+
items = result[:invoiceItems]
|
|
41
|
+
error! INVALID_RESPONSE unless items.is_a?(Array) && result[:currency].present?
|
|
42
|
+
|
|
43
|
+
items.map { |item| normalize_item(item, result.fetch(:currency)) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @rbs (untyped item, ::String currency) -> untyped
|
|
47
|
+
def normalize_item(item, currency)
|
|
48
|
+
created_at = item.dig(:logs, :createdAt)
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
external_id: item.fetch(:id),
|
|
52
|
+
normal_type: item[:normalType],
|
|
53
|
+
amount: item[:totalPrice]&.to_d,
|
|
54
|
+
currency:,
|
|
55
|
+
occurred_at: (Time.iso8601(created_at) if created_at.present?)
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Guests
|
|
3
|
+
module Profiles
|
|
4
|
+
class Create
|
|
5
|
+
include Guesty::Operation
|
|
6
|
+
|
|
7
|
+
REQUIRED_ATTRIBUTES = %i[firstName lastName].freeze #: ::Array[::Symbol]
|
|
8
|
+
|
|
9
|
+
# @rbs (attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
10
|
+
def call(attributes:)
|
|
11
|
+
argument! attributes: :required, fatal: true if attributes.blank?
|
|
12
|
+
|
|
13
|
+
missing = REQUIRED_ATTRIBUTES.find { |attribute| attributes[attribute].blank? }
|
|
14
|
+
argument! missing => :required if missing
|
|
15
|
+
|
|
16
|
+
response = Guesty.do_request :post, 'guests-crud', attributes, retries: 0
|
|
17
|
+
|
|
18
|
+
raise response.failure unless response.success?
|
|
19
|
+
|
|
20
|
+
Success(response.value!.body)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Guests
|
|
3
|
+
module Profiles
|
|
4
|
+
class Show
|
|
5
|
+
include Guesty::Operation
|
|
6
|
+
|
|
7
|
+
# @rbs (guest_id: ::String, ?fields: ::Array[::String]?) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
8
|
+
def call(guest_id:, fields: nil)
|
|
9
|
+
argument! guest_id: :required if guest_id.blank?
|
|
10
|
+
|
|
11
|
+
attributes = {} #: ::Hash[::Symbol, ::String]
|
|
12
|
+
attributes[:fields] = fields.join(' ') if fields.present?
|
|
13
|
+
|
|
14
|
+
response = Guesty.do_request :get, "guests-crud/#{guest_id}", attributes
|
|
15
|
+
|
|
16
|
+
raise response.failure unless response.success?
|
|
17
|
+
|
|
18
|
+
Success(response.value!.body)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Guests
|
|
3
|
+
module Profiles
|
|
4
|
+
class Update
|
|
5
|
+
include Guesty::Operation
|
|
6
|
+
|
|
7
|
+
# @rbs (guest_id: ::String, attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
8
|
+
def call(guest_id:, attributes:)
|
|
9
|
+
argument! guest_id: :required if guest_id.blank?
|
|
10
|
+
argument! attributes: :required, fatal: true if attributes.blank?
|
|
11
|
+
|
|
12
|
+
response = Guesty.do_request :put, "guests-crud/#{guest_id}", prepare_tags(guest_id, attributes)
|
|
13
|
+
|
|
14
|
+
raise response.failure unless response.success?
|
|
15
|
+
|
|
16
|
+
Success(response.value!.body)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# Actions on a single tag are not supported by Guesty.
|
|
22
|
+
# Instead, we fetch all tags and upload a modified array.
|
|
23
|
+
def prepare_tags(guest_id, attributes)
|
|
24
|
+
tags_to_add = attributes[:addTags] || []
|
|
25
|
+
tags_to_remove = attributes[:removeTags] || []
|
|
26
|
+
payload = attributes.except(:addTags, :removeTags)
|
|
27
|
+
|
|
28
|
+
return payload if tags_to_add.blank? && tags_to_remove.blank?
|
|
29
|
+
|
|
30
|
+
current_profile = Show.new.call!(guest_id:, fields: ['tags']).value!
|
|
31
|
+
payload.merge(tags: (current_profile[:tags] | tags_to_add) - tags_to_remove)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Homes
|
|
3
|
+
module Calendars
|
|
4
|
+
class Show
|
|
5
|
+
include Guesty::Operation
|
|
6
|
+
|
|
7
|
+
BLOCK_TYPE_MAPPING = {
|
|
8
|
+
m: :manual_block,
|
|
9
|
+
r: :reserved_date,
|
|
10
|
+
b: :booked_reservation,
|
|
11
|
+
bd: :blocked_by_default,
|
|
12
|
+
sr: :smart_calendar_rule,
|
|
13
|
+
abl: :annual_booking_limit,
|
|
14
|
+
a: :allotment_block,
|
|
15
|
+
bw: :booking_window,
|
|
16
|
+
o: :owner_reservation,
|
|
17
|
+
pt: :preparation_time,
|
|
18
|
+
ic: :ical_import,
|
|
19
|
+
an: :advance_notice,
|
|
20
|
+
cio: :check_in_out
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# @rbs (home_id: ::String, start_date: ::Date, end_date: ::Date) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
24
|
+
def call(home_id:, start_date:, end_date:)
|
|
25
|
+
argument! home_id: :required if home_id.blank?
|
|
26
|
+
argument! end_date: :invalid if end_date <= start_date
|
|
27
|
+
|
|
28
|
+
response = fetch_calendar_data(home_id, start_date, end_date)
|
|
29
|
+
|
|
30
|
+
raise response.failure if response.failure?
|
|
31
|
+
|
|
32
|
+
Success(normalize_calendar(response.value!.body))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def fetch_calendar_data(home_id, start_date, end_date)
|
|
38
|
+
params = {
|
|
39
|
+
startDate: start_date,
|
|
40
|
+
endDate: end_date,
|
|
41
|
+
view: 'full'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Guesty.do_request :get, "availability-pricing/api/calendar/listings/minified/#{home_id}", params
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_calendar(body)
|
|
48
|
+
calendar = body.dig(:data, :days) || {}
|
|
49
|
+
@blocks = calendar[:blocks] || {}
|
|
50
|
+
@days = calendar[:calendar] || []
|
|
51
|
+
available, unavailable = @days.partition { |day| available_day?(day) }
|
|
52
|
+
|
|
53
|
+
validate_currency(calendar[:currency], @days)
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
available: attrs_for_available(available),
|
|
57
|
+
unavailable: attrs_for_unavailable(unavailable),
|
|
58
|
+
blocks: attrs_for_blocks
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The host prices in one currency; a listing in another would silently
|
|
63
|
+
# misprice. Skipped when the host has not configured one.
|
|
64
|
+
# @rbs (::String? currency, ::Array[::Hash[::Symbol, untyped]] days) -> void
|
|
65
|
+
def validate_currency(currency, days)
|
|
66
|
+
return nil if days.empty? || Guesty.config.default_currency.nil?
|
|
67
|
+
return nil if currency.to_s.upcase == Guesty.config.default_currency
|
|
68
|
+
|
|
69
|
+
error! "unsupported currency #{currency}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @rbs (::Hash[::Symbol, untyped] day) -> bool
|
|
73
|
+
def available_day?(day)
|
|
74
|
+
day[:blocks].empty? || day_is_available_for_checkout?(day)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @rbs (::Hash[::Symbol, untyped] day) -> bool
|
|
78
|
+
def day_is_available_for_checkout?(day)
|
|
79
|
+
return false if day[:blocks].except(:b).any?
|
|
80
|
+
return false if previous_night_blocked?(day)
|
|
81
|
+
|
|
82
|
+
day[:blockIds].any? { |id| @blocks.dig(id.to_sym, :reservation, :checkInDateLocalized) == day[:date] }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @rbs (::Hash[::Symbol, untyped] day) -> bool
|
|
86
|
+
def previous_night_blocked?(day)
|
|
87
|
+
blocked_dates.include?(day[:date].to_date.prev_day.iso8601)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @rbs () -> ::Set[::String]
|
|
91
|
+
def blocked_dates
|
|
92
|
+
@blocked_dates ||= @days.reject { |day| day[:blocks].empty? }.to_set { |day| day[:date] }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @rbs (::Array[::Hash[::Symbol, untyped]] days) -> ::Array[::Hash[::Symbol, untyped]]
|
|
96
|
+
def attrs_for_available(days)
|
|
97
|
+
days.map do |day|
|
|
98
|
+
{
|
|
99
|
+
date: day[:date],
|
|
100
|
+
price: day[:price],
|
|
101
|
+
min_nights: day[:minNights],
|
|
102
|
+
check_in: day[:blocks].empty?
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @rbs (::Array[::Hash[::Symbol, untyped]] days) -> ::Array[::Hash[::Symbol, untyped]]
|
|
108
|
+
def attrs_for_unavailable(days)
|
|
109
|
+
days.map do |day|
|
|
110
|
+
{
|
|
111
|
+
date: day[:date],
|
|
112
|
+
price: day[:price],
|
|
113
|
+
min_nights: day[:minNights],
|
|
114
|
+
check_in: false,
|
|
115
|
+
blocked_as: day[:blocks].keys.filter_map { |key| BLOCK_TYPE_MAPPING[key] }
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @rbs () -> ::Array[::Hash[::Symbol, untyped]]
|
|
121
|
+
def attrs_for_blocks
|
|
122
|
+
@blocks.values.map do |block|
|
|
123
|
+
{
|
|
124
|
+
kind: BLOCK_TYPE_MAPPING[block[:type].to_sym],
|
|
125
|
+
start_date: block[:startDate].to_date,
|
|
126
|
+
end_date: block[:endDate].to_date,
|
|
127
|
+
comment: block[:note].presence
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Homes
|
|
3
|
+
# `active` is a filter, so all listings take two passes. Tags come back as
|
|
4
|
+
# ids on the listing; tag names live on GET listings/tags.
|
|
5
|
+
class Search
|
|
6
|
+
include Guesty::Operation
|
|
7
|
+
|
|
8
|
+
# @example
|
|
9
|
+
# attributes: {
|
|
10
|
+
# params: { ids: 'guestyID', available: { checkIn: "YYYY-MM-DD", checkOut: "YYYY-MM-DD", minOccupancy: 3 }.to_json }
|
|
11
|
+
# filters: [{ operator: '$eq', field: 'type', value: 'MTL_CHILD' }],
|
|
12
|
+
# response_keys: %w[
|
|
13
|
+
# _id
|
|
14
|
+
# nickname
|
|
15
|
+
# type
|
|
16
|
+
# ]
|
|
17
|
+
# }
|
|
18
|
+
|
|
19
|
+
# @rbs (attributes: ::Hash[::Symbol, untyped], ?skip: ::Integer, ?take: ::Integer) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
20
|
+
def call(attributes:, skip: 0, take: 100)
|
|
21
|
+
argument! take: :invalid if take.blank? || take <= 0
|
|
22
|
+
|
|
23
|
+
response = Guesty.do_request :get, 'listings', query(attributes, skip, take)
|
|
24
|
+
|
|
25
|
+
raise response.failure unless response.success?
|
|
26
|
+
|
|
27
|
+
body = response.value!.body
|
|
28
|
+
|
|
29
|
+
Success(body.slice(:results, :count))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def query(attributes, skip, take)
|
|
35
|
+
{
|
|
36
|
+
active: true,
|
|
37
|
+
fields: (attributes[:response_keys] || []).join(' '),
|
|
38
|
+
limit: take,
|
|
39
|
+
skip:,
|
|
40
|
+
**(attributes[:filters].present? ? { filters: attributes[:filters].to_json } : {}),
|
|
41
|
+
**(attributes[:params] || {})
|
|
42
|
+
}.compact_blank
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
# The default rate limiter: in-memory fixed windows per key. Per-process only;
|
|
3
|
+
# a host running several processes injects a shared backend, such as Redis,
|
|
4
|
+
# via Guesty.config.rate_limiter.
|
|
5
|
+
class MemoryRateLimiter
|
|
6
|
+
def initialize
|
|
7
|
+
@windows = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# @rbs (key: ::Symbol, threshold: ::Integer, interval: untyped) -> void
|
|
11
|
+
def throttle!(key:, threshold:, interval:)
|
|
12
|
+
now = Time.now
|
|
13
|
+
window_start, count = @windows.fetch(key, [now, 0])
|
|
14
|
+
|
|
15
|
+
if now >= window_start + interval
|
|
16
|
+
window_start = now
|
|
17
|
+
count = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
count += 1
|
|
21
|
+
@windows[key] = [window_start, count]
|
|
22
|
+
|
|
23
|
+
return if count <= threshold
|
|
24
|
+
|
|
25
|
+
raise ThrottledError.new(key:, threshold:, interval:, retry_after: (window_start + interval) - now)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|