diff --git a/.gitea/workflows/deploy-mail.yml b/.gitea/workflows/deploy-mail.yml new file mode 100644 index 0000000..a2e91dc --- /dev/null +++ b/.gitea/workflows/deploy-mail.yml @@ -0,0 +1,78 @@ +name: Deploy Promiscuity Mail API + +on: + push: + branches: + - main + workflow_dispatch: {} + +jobs: + deploy: + runs-on: self-hosted + + env: + IMAGE_NAME: promiscuity-mail:latest + IMAGE_TAR: /tmp/promiscuity-mail.tar + NODES: "192.168.86.72 192.168.86.73 192.168.86.74" + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Build Docker image + run: | + cd microservices/MailApi + docker build -t "${IMAGE_NAME}" . + + - name: Save Docker image to TAR + run: | + docker save "${IMAGE_NAME}" -o "${IMAGE_TAR}" + + - name: Copy TAR to nodes + run: | + for node in ${NODES}; do + scp -o StrictHostKeyChecking=no "${IMAGE_TAR}" hz@"$node":/tmp/promiscuity-mail.tar + done + + - name: Import image on nodes + run: | + for node in ${NODES}; do + ssh -o StrictHostKeyChecking=no hz@"$node" "sudo ctr -n k8s.io images import /tmp/promiscuity-mail.tar" + done + + - name: Clean TAR from nodes + run: | + for node in ${NODES}; do + ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/promiscuity-mail.tar" + done + + - name: Clean TAR on runner + run: | + rm -f "${IMAGE_TAR}" + + - name: Write kubeconfig from secret + env: + KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }} + run: | + mkdir -p /tmp/kube + printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config + + - name: Create namespace if missing + env: + KUBECONFIG: /tmp/kube/config + run: | + kubectl create namespace promiscuity-mail --dry-run=client -o yaml | kubectl apply -f - + + - name: Apply Mail deployment & service + env: + KUBECONFIG: /tmp/kube/config + run: | + kubectl apply -f microservices/MailApi/k8s/deployment.yaml -n promiscuity-mail + kubectl apply -f microservices/MailApi/k8s/service.yaml -n promiscuity-mail + + - name: Restart Mail deployment + env: + KUBECONFIG: /tmp/kube/config + run: | + kubectl rollout restart deployment/promiscuity-mail -n promiscuity-mail + kubectl rollout status deployment/promiscuity-mail -n promiscuity-mail diff --git a/game/scenes/Levels/location_level.gd b/game/scenes/Levels/location_level.gd index a9c20fa..e8c0a8b 100644 --- a/game/scenes/Levels/location_level.gd +++ b/game/scenes/Levels/location_level.gd @@ -3,6 +3,7 @@ extends Node3D const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters" const LOCATION_API_URL := "https://ploc.ranaze.com/api/Locations" const INVENTORY_API_URL := "https://pinv.ranaze.com/api/inventory" +const MAIL_API_URL := "https://pmail.ranaze.com/api/mail" const START_SCREEN_SCENE := "res://scenes/UI/start_screen.tscn" const SETTINGS_SCENE := "res://scenes/UI/Settings.tscn" const CHARACTER_SLOT_COUNT := 6 @@ -34,6 +35,16 @@ const HEARTBEAT_INTERVAL := 10.0 @onready var _target_slot_spin_box: SpinBox = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ControlsRow/TargetSlotSpinBox @onready var _quantity_spin_box: SpinBox = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ControlsRow/QuantitySpinBox @onready var _inventory_status_label: Label = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/StatusLabel +@onready var _mail_menu: CanvasLayer = $MailMenu +@onready var _mail_location_label: Label = $MailMenu/MarginContainer/Panel/VBoxContainer/MailboxLocationLabel +@onready var _mail_inbox_items: ItemList = $MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel/VBoxContainer/InboxItems +@onready var _mail_sent_items: ItemList = $MailMenu/MarginContainer/Panel/VBoxContainer/Columns/SentPanel/VBoxContainer/SentItems +@onready var _mail_selected_label: Label = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/SelectedMailLabel +@onready var _mail_selected_body: TextEdit = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/SelectedMailBody +@onready var _mail_recipient_line_edit: LineEdit = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/RecipientLineEdit +@onready var _mail_subject_line_edit: LineEdit = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/SubjectLineEdit +@onready var _mail_compose_body: TextEdit = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/ComposeBody +@onready var _mail_status_label: Label = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/StatusLabel var _center_coord := Vector2i.ZERO var _tiles_root: Node3D @@ -61,6 +72,11 @@ var _heartbeat_in_flight := false var _visible_character_refresh_elapsed := 0.0 var _heartbeat_elapsed := 0.0 var _remote_character_nodes: Dictionary = {} +var _mail_inbox: Array = [] +var _mail_sent: Array = [] +var _mail_request_in_flight := false +var _selected_inbox_mail_id := "" +var _selected_sent_mail_id := "" func _ready() -> void: @@ -104,7 +120,7 @@ func _process(_delta: float) -> void: if _heartbeat_elapsed >= HEARTBEAT_INTERVAL: _heartbeat_elapsed = 0.0 _send_presence_heartbeat() - if _inventory_menu.visible: + if _inventory_menu.visible or _mail_menu.visible: return var target_world_pos := _get_stream_position() var target_coord := _world_to_coord(target_world_pos) @@ -117,13 +133,17 @@ func _process(_delta: float) -> void: func _input(event: InputEvent) -> void: if event.is_action_pressed("player_phone"): - if get_tree().paused: + if get_tree().paused or _mail_menu.visible: return _toggle_inventory_menu() get_viewport().set_input_as_handled() func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel") and _mail_menu.visible: + _close_mail_menu() + get_viewport().set_input_as_handled() + return if event.is_action_pressed("ui_cancel") and _inventory_menu.visible: _close_inventory_menu() get_viewport().set_input_as_handled() @@ -197,6 +217,8 @@ func _toggle_pause_menu() -> void: func _pause_game() -> void: if _inventory_menu.visible: _close_inventory_menu() + if _mail_menu.visible: + _close_mail_menu() get_tree().paused = true _pause_menu.visible = true Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) @@ -209,6 +231,8 @@ func _resume_game() -> void: func _toggle_inventory_menu() -> void: + if _mail_menu.visible: + return if _inventory_menu.visible: _close_inventory_menu() return @@ -235,6 +259,28 @@ func _close_inventory_menu() -> void: _set_player_menu_lock(false) +func _open_mail_menu() -> void: + _mail_menu.visible = true + _mail_status_label.text = "Loading mailbox..." + _selected_inbox_mail_id = "" + _selected_sent_mail_id = "" + _set_player_menu_lock(true) + _update_mail_location_label() + _refresh_mailbox() + + +func _close_mail_menu() -> void: + _mail_menu.visible = false + _mail_status_label.text = "" + _set_player_menu_lock(false) + + +func _update_mail_location_label() -> void: + var location_data := _get_location_data(_center_coord) + var location_name := String(location_data.get("name", "Mailbox")).strip_edges() + _mail_location_label.text = "%s (%d,%d)" % [location_name, _center_coord.x, _center_coord.y] + + func _set_player_menu_lock(locked: bool) -> void: if _player == null: return @@ -503,14 +549,16 @@ func _create_object_material(object_key: String) -> StandardMaterial3D: material.roughness = 1.0 material.metallic = 0.0 - if object_key.contains("grass"): - material.albedo_color = Color(0.28, 0.68, 0.25, 1.0) - elif object_key.contains("wood"): - material.albedo_color = Color(0.54, 0.36, 0.18, 1.0) - elif object_key.contains("stone"): - material.albedo_color = Color(0.55, 0.57, 0.6, 1.0) - else: - material.albedo_color = Color(0.85, 0.75, 0.3, 1.0) + if object_key.contains("grass"): + material.albedo_color = Color(0.28, 0.68, 0.25, 1.0) + elif object_key.contains("wood"): + material.albedo_color = Color(0.54, 0.36, 0.18, 1.0) + elif object_key.contains("stone"): + material.albedo_color = Color(0.55, 0.57, 0.6, 1.0) + elif object_key.contains("mailbox"): + material.albedo_color = Color(0.22, 0.44, 0.78, 1.0) + else: + material.albedo_color = Color(0.85, 0.75, 0.3, 1.0) return material @@ -894,6 +942,266 @@ func _fetch_character_inventory() -> Array: var payload := parsed as Dictionary return _parse_floor_inventory_items(payload.get("items", [])) + + +func _refresh_mailbox() -> void: + if _mail_request_in_flight or _character_id.is_empty(): + return + _refresh_mailbox_async() + + +func _refresh_mailbox_async() -> void: + _mail_request_in_flight = true + var request := HTTPRequest.new() + add_child(request) + + var headers := PackedStringArray() + if not AuthState.access_token.is_empty(): + headers.append("Authorization: Bearer %s" % AuthState.access_token) + + var err := request.request("%s/characters/%s" % [MAIL_API_URL, _character_id], headers, HTTPClient.METHOD_GET) + if err != OK: + request.queue_free() + _mail_status_label.text = "Failed to load mailbox." + _mail_request_in_flight = false + return + + var result: Array = await request.request_completed + request.queue_free() + _mail_request_in_flight = false + + var result_code: int = result[0] + var response_code: int = result[1] + var response_body: String = result[3].get_string_from_utf8() + if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + _mail_status_label.text = "Failed to load mailbox." + push_warning("Failed to load mailbox (%s/%s): %s" % [result_code, response_code, response_body]) + return + + var parsed: Variant = JSON.parse_string(response_body) + if typeof(parsed) != TYPE_DICTIONARY: + _mail_status_label.text = "Mailbox response was invalid." + return + + var mailbox := parsed as Dictionary + _mail_inbox = _parse_mail_messages(mailbox.get("inbox", [])) + _mail_sent = _parse_mail_messages(mailbox.get("sent", [])) + _render_mailbox() + _mail_status_label.text = "" + + +func _render_mailbox() -> void: + var current_inbox_id := _selected_inbox_mail_id + var current_sent_id := _selected_sent_mail_id + _mail_inbox_items.clear() + for index in range(_mail_inbox.size()): + var message := _mail_inbox[index] as Dictionary + var prefix := "[NEW] " if String(message.get("readUtc", "")).is_empty() else "" + var text := "%s%s | %s" % [ + prefix, + String(message.get("senderCharacterName", "")).strip_edges(), + String(message.get("subject", "")).strip_edges() + ] + _mail_inbox_items.add_item(text) + _mail_inbox_items.set_item_metadata(index, message) + if String(message.get("id", "")).strip_edges() == current_inbox_id: + _mail_inbox_items.select(index) + + _mail_sent_items.clear() + for index in range(_mail_sent.size()): + var message := _mail_sent[index] as Dictionary + var text := "%s | %s" % [ + String(message.get("recipientCharacterName", "")).strip_edges(), + String(message.get("subject", "")).strip_edges() + ] + _mail_sent_items.add_item(text) + _mail_sent_items.set_item_metadata(index, message) + if String(message.get("id", "")).strip_edges() == current_sent_id: + _mail_sent_items.select(index) + + _update_mail_selection_display() + + +func _parse_mail_messages(value: Variant) -> Array: + var messages: Array = [] + if typeof(value) != TYPE_ARRAY: + return messages + + for entry in value: + if typeof(entry) != TYPE_DICTIONARY: + continue + var message := entry as Dictionary + messages.append({ + "id": String(message.get("id", "")).strip_edges(), + "senderCharacterId": String(message.get("senderCharacterId", "")).strip_edges(), + "senderCharacterName": String(message.get("senderCharacterName", "")).strip_edges(), + "recipientCharacterId": String(message.get("recipientCharacterId", "")).strip_edges(), + "recipientCharacterName": String(message.get("recipientCharacterName", "")).strip_edges(), + "subject": String(message.get("subject", "")).strip_edges(), + "body": String(message.get("body", "")).strip_edges(), + "createdUtc": String(message.get("createdUtc", "")).strip_edges(), + "readUtc": String(message.get("readUtc", "")).strip_edges() + }) + + return messages + + +func _update_mail_selection_display() -> void: + var selected_message := {} + if not _selected_inbox_mail_id.is_empty(): + selected_message = _find_mail_message_by_id(_mail_inbox, _selected_inbox_mail_id) + elif not _selected_sent_mail_id.is_empty(): + selected_message = _find_mail_message_by_id(_mail_sent, _selected_sent_mail_id) + + if selected_message.is_empty(): + _mail_selected_label.text = "Selected Mail" + _mail_selected_body.text = "" + return + + var subject := String(selected_message.get("subject", "")).strip_edges() + var sender_name := String(selected_message.get("senderCharacterName", "")).strip_edges() + var recipient_name := String(selected_message.get("recipientCharacterName", "")).strip_edges() + _mail_selected_label.text = "%s -> %s | %s" % [sender_name, recipient_name, subject] + _mail_selected_body.text = String(selected_message.get("body", "")).strip_edges() + + +func _find_mail_message_by_id(messages: Array, message_id: String) -> Dictionary: + for message_variant in messages: + if typeof(message_variant) != TYPE_DICTIONARY: + continue + var message := message_variant as Dictionary + if String(message.get("id", "")).strip_edges() == message_id: + return message + return {} + + +func _on_mail_inbox_selected(index: int) -> void: + var metadata: Variant = _mail_inbox_items.get_item_metadata(index) + if typeof(metadata) != TYPE_DICTIONARY: + return + var message := metadata as Dictionary + _selected_sent_mail_id = "" + _mail_sent_items.deselect_all() + _selected_inbox_mail_id = String(message.get("id", "")).strip_edges() + _update_mail_selection_display() + _mark_mail_read(_selected_inbox_mail_id) + + +func _on_mail_sent_selected(index: int) -> void: + var metadata: Variant = _mail_sent_items.get_item_metadata(index) + if typeof(metadata) != TYPE_DICTIONARY: + return + var message := metadata as Dictionary + _selected_inbox_mail_id = "" + _mail_inbox_items.deselect_all() + _selected_sent_mail_id = String(message.get("id", "")).strip_edges() + _update_mail_selection_display() + + +func _mark_mail_read(message_id: String) -> void: + if message_id.is_empty(): + return + _mark_mail_read_async(message_id) + + +func _mark_mail_read_async(message_id: String) -> void: + var request := HTTPRequest.new() + add_child(request) + + var headers := PackedStringArray() + if not AuthState.access_token.is_empty(): + headers.append("Authorization: Bearer %s" % AuthState.access_token) + + var err := request.request("%s/characters/%s/messages/%s/read" % [MAIL_API_URL, _character_id, message_id], headers, HTTPClient.METHOD_POST) + if err != OK: + request.queue_free() + return + + var result: Array = await request.request_completed + request.queue_free() + var result_code: int = result[0] + var response_code: int = result[1] + if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + return + + for index in range(_mail_inbox.size()): + var message := _mail_inbox[index] as Dictionary + if String(message.get("id", "")).strip_edges() != message_id: + continue + message["readUtc"] = "read" + _mail_inbox[index] = message + break + _render_mailbox() + + +func _on_mail_refresh_pressed() -> void: + _mail_status_label.text = "Refreshing mailbox..." + _refresh_mailbox() + + +func _on_mail_send_pressed() -> void: + var recipient_name := _mail_recipient_line_edit.text.strip_edges() + var subject := _mail_subject_line_edit.text.strip_edges() + var body := _mail_compose_body.text.strip_edges() + if recipient_name.is_empty(): + _mail_status_label.text = "Recipient character name is required." + return + if subject.is_empty(): + _mail_status_label.text = "Subject is required." + return + if body.is_empty(): + _mail_status_label.text = "Body is required." + return + _send_mail_async(recipient_name, subject, body) + + +func _send_mail_async(recipient_name: String, subject: String, body: String) -> void: + if _mail_request_in_flight: + return + _mail_request_in_flight = true + _mail_status_label.text = "Sending mail..." + + var request := HTTPRequest.new() + add_child(request) + + var headers := PackedStringArray() + if not AuthState.access_token.is_empty(): + headers.append("Authorization: Bearer %s" % AuthState.access_token) + headers.append("Content-Type: application/json") + + var payload := JSON.stringify({ + "recipientCharacterName": recipient_name, + "subject": subject, + "body": body + }) + var err := request.request("%s/characters/%s/send" % [MAIL_API_URL, _character_id], headers, HTTPClient.METHOD_POST, payload) + if err != OK: + request.queue_free() + _mail_request_in_flight = false + _mail_status_label.text = "Failed to send mail." + return + + var result: Array = await request.request_completed + request.queue_free() + _mail_request_in_flight = false + + var result_code: int = result[0] + var response_code: int = result[1] + var response_body: String = result[3].get_string_from_utf8() + if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + _mail_status_label.text = "Failed to send mail." + push_warning("Failed to send mail (%s/%s): %s" % [result_code, response_code, response_body]) + return + + _mail_recipient_line_edit.text = "" + _mail_subject_line_edit.text = "" + _mail_compose_body.text = "" + _mail_status_label.text = "Mail sent." + _refresh_mailbox() + + +func _on_mail_close_pressed() -> void: + _close_mail_menu() func _ensure_selected_location_exists(coord: Vector2i) -> void: @@ -1047,7 +1355,7 @@ func _try_interact_current_tile() -> void: _interact_with_location_async(location_id, object_id) -func _interact_with_location_async(location_id: String, object_id: String) -> void: +func _interact_with_location_async(location_id: String, object_id: String) -> void: var request := HTTPRequest.new() add_child(request) @@ -1079,12 +1387,16 @@ func _interact_with_location_async(location_id: String, object_id: String) -> vo return var parsed: Variant = JSON.parse_string(response_body) - if typeof(parsed) != TYPE_DICTIONARY: - push_warning("Location interaction response was not an object.") - _interact_in_flight = false - return - + if typeof(parsed) != TYPE_DICTIONARY: + push_warning("Location interaction response was not an object.") + _interact_in_flight = false + return + var interaction := parsed as Dictionary + if String(interaction.get("objectType", "")).strip_edges() == "mailbox": + _open_mail_menu() + _interact_in_flight = false + return _apply_interaction_result(location_id, interaction) await _refresh_location_inventory(location_id) _interact_in_flight = false diff --git a/game/scenes/Levels/location_level.tscn b/game/scenes/Levels/location_level.tscn index 0a48720..9f563c3 100644 --- a/game/scenes/Levels/location_level.tscn +++ b/game/scenes/Levels/location_level.tscn @@ -234,6 +234,142 @@ text = "CLOSE" layout_mode = 2 text = "" +[node name="MailMenu" type="CanvasLayer" parent="."] +visible = false +layer = 2 + +[node name="Overlay" type="ColorRect" parent="MailMenu"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0, 0, 0, 0.4) + +[node name="MarginContainer" type="MarginContainer" parent="MailMenu"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 48 +theme_override_constants/margin_top = 48 +theme_override_constants/margin_right = 48 +theme_override_constants/margin_bottom = 48 + +[node name="Panel" type="PanelContainer" parent="MailMenu/MarginContainer"] +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="MailMenu/MarginContainer/Panel"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="TitleLabel" type="Label" parent="MailMenu/MarginContainer/Panel/VBoxContainer"] +layout_mode = 2 +text = "Mailbox" +horizontal_alignment = 1 + +[node name="MailboxLocationLabel" type="Label" parent="MailMenu/MarginContainer/Panel/VBoxContainer"] +layout_mode = 2 +text = "Mailbox" +horizontal_alignment = 1 + +[node name="Columns" type="HBoxContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/separation = 12 + +[node name="InboxPanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns"] +layout_mode = 2 +size_flags_horizontal = 2 +size_flags_vertical = 3 + +[node name="VBoxContainer" type="VBoxContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="InboxLabel" type="Label" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel/VBoxContainer"] +layout_mode = 2 +text = "Inbox" +horizontal_alignment = 1 + +[node name="InboxItems" type="ItemList" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel/VBoxContainer"] +custom_minimum_size = Vector2(0, 220) +layout_mode = 2 +size_flags_vertical = 3 + +[node name="SentPanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns"] +layout_mode = 2 +size_flags_horizontal = 2 +size_flags_vertical = 3 + +[node name="VBoxContainer" type="VBoxContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/SentPanel"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="SentLabel" type="Label" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/SentPanel/VBoxContainer"] +layout_mode = 2 +text = "Sent" +horizontal_alignment = 1 + +[node name="SentItems" type="ItemList" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/SentPanel/VBoxContainer"] +custom_minimum_size = Vector2(0, 220) +layout_mode = 2 +size_flags_vertical = 3 + +[node name="DetailsComposePanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer"] +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="SelectedMailLabel" type="Label" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer"] +layout_mode = 2 +text = "Selected Mail" + +[node name="SelectedMailBody" type="TextEdit" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer"] +custom_minimum_size = Vector2(0, 120) +layout_mode = 2 +editable = false + +[node name="RecipientLineEdit" type="LineEdit" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer"] +layout_mode = 2 +placeholder_text = "Recipient character name" + +[node name="SubjectLineEdit" type="LineEdit" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer"] +layout_mode = 2 +placeholder_text = "Subject" + +[node name="ComposeBody" type="TextEdit" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer"] +custom_minimum_size = Vector2(0, 120) +layout_mode = 2 +placeholder_text = "Write your mail here" + +[node name="ActionRow" type="HBoxContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="RefreshButton" type="Button" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/ActionRow"] +layout_mode = 2 +theme = ExtResource("4_button_theme") +text = "REFRESH" + +[node name="SendButton" type="Button" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/ActionRow"] +layout_mode = 2 +theme = ExtResource("4_button_theme") +text = "SEND" + +[node name="CloseButton" type="Button" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/ActionRow"] +layout_mode = 2 +theme = ExtResource("4_button_theme") +text = "CLOSE" + +[node name="StatusLabel" type="Label" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer"] +layout_mode = 2 +text = "" + [connection signal="pressed" from="PauseMenu/CenterContainer/Panel/VBoxContainer/ContinueButton" to="." method="_on_pause_continue_pressed"] [connection signal="pressed" from="PauseMenu/CenterContainer/Panel/VBoxContainer/SettingsButton" to="." method="_on_pause_settings_pressed"] [connection signal="pressed" from="PauseMenu/CenterContainer/Panel/VBoxContainer/MainMenuButton" to="." method="_on_pause_main_menu_pressed"] @@ -244,3 +380,8 @@ text = "" [connection signal="pressed" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ActionRow/PickupButton" to="." method="_on_inventory_pickup_pressed"] [connection signal="pressed" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ActionRow/RefreshButton" to="." method="_on_inventory_refresh_pressed"] [connection signal="pressed" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ActionRow/CloseButton" to="." method="_on_inventory_close_pressed"] +[connection signal="item_selected" from="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel/VBoxContainer/InboxItems" to="." method="_on_mail_inbox_selected"] +[connection signal="item_selected" from="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/SentPanel/VBoxContainer/SentItems" to="." method="_on_mail_sent_selected"] +[connection signal="pressed" from="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/ActionRow/RefreshButton" to="." method="_on_mail_refresh_pressed"] +[connection signal="pressed" from="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/ActionRow/SendButton" to="." method="_on_mail_send_pressed"] +[connection signal="pressed" from="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/ActionRow/CloseButton" to="." method="_on_mail_close_pressed"] diff --git a/microservices/CharacterApi/Controllers/CharactersController.cs b/microservices/CharacterApi/Controllers/CharactersController.cs index 56bee80..1e5770a 100644 --- a/microservices/CharacterApi/Controllers/CharactersController.cs +++ b/microservices/CharacterApi/Controllers/CharactersController.cs @@ -2,6 +2,7 @@ using CharacterApi.Models; using CharacterApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using MongoDB.Driver; using System.Net.Http.Headers; using System.Security.Claims; using System.Text; @@ -47,10 +48,18 @@ public class CharactersController : ControllerBase LastSeenUtc = DateTime.UtcNow, CreatedUtc = DateTime.UtcNow }; - - await _characters.CreateAsync(character); - return Ok(character); - } + + try + { + await _characters.CreateAsync(character); + } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return Conflict("Character name is already taken"); + } + + return Ok(character); + } [HttpGet] [Authorize(Roles = "USER,SUPER")] diff --git a/microservices/CharacterApi/Services/CharacterStore.cs b/microservices/CharacterApi/Services/CharacterStore.cs index 14392ad..85c80be 100644 --- a/microservices/CharacterApi/Services/CharacterStore.cs +++ b/microservices/CharacterApi/Services/CharacterStore.cs @@ -17,6 +17,8 @@ public class CharacterStore var ownerIndex = Builders.IndexKeys.Ascending(c => c.OwnerUserId); _col.Indexes.CreateOne(new CreateIndexModel(ownerIndex)); + var nameIndex = Builders.IndexKeys.Ascending(c => c.Name); + _col.Indexes.CreateOne(new CreateIndexModel(nameIndex, new CreateIndexOptions { Unique = true })); var coordIndex = Builders.IndexKeys.Ascending("Coord.X").Ascending("Coord.Y"); _col.Indexes.CreateOne(new CreateIndexModel(coordIndex)); } diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index e947f7f..8aa35af 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -188,6 +188,23 @@ public class LocationsController : ControllerBase return BadRequest("Location object type is not supported"); if (interact.Status == InteractStatus.ObjectConsumed) return Conflict("Location object is consumed"); + if (string.Equals(interact.ObjectType, "mailbox", StringComparison.OrdinalIgnoreCase)) + { + return Ok(new InteractLocationObjectResponse + { + LocationId = id, + CharacterId = req.CharacterId, + ObjectId = req.ObjectId, + ObjectType = interact.ObjectType, + ItemKey = string.Empty, + QuantityGranted = 0, + CharacterGrantedQuantity = 0, + FloorGrantedQuantity = 0, + RemainingQuantity = 0, + Consumed = false, + InventoryResponseJson = string.Empty + }); + } var inventoryBaseUrl = (_configuration["Services:InventoryApiBaseUrl"] ?? "http://localhost:5003").TrimEnd('/'); var token = Request.Headers.Authorization.ToString(); diff --git a/microservices/LocationsApi/Services/LocationStore.cs b/microservices/LocationsApi/Services/LocationStore.cs index ffd0362..9d474e3 100644 --- a/microservices/LocationsApi/Services/LocationStore.cs +++ b/microservices/LocationsApi/Services/LocationStore.cs @@ -190,6 +190,18 @@ public class LocationStore return new InteractResult(InteractStatus.ObjectNotFound); if (!string.Equals(locationObject.ObjectId, objectId, StringComparison.Ordinal)) return new InteractResult(InteractStatus.ObjectNotFound); + if (string.Equals(locationObject.ObjectType, "mailbox", StringComparison.OrdinalIgnoreCase)) + { + return new InteractResult( + InteractStatus.Ok, + locationObject.ObjectId, + locationObject.ObjectType, + "", + 0, + 0, + false, + CloneLocationObject(locationObject)); + } if (!string.Equals(locationObject.ObjectType, "gatherable", StringComparison.OrdinalIgnoreCase)) return new InteractResult(InteractStatus.UnsupportedObjectType); if (locationObject.State.RemainingQuantity <= 0) @@ -462,7 +474,9 @@ public class LocationStore biomeKey = await DetermineBiomeKeyAsync(location.Coord.X, location.Coord.Y, biomeDefinitions); var elevation = hasElevation ? location.Elevation : await DetermineElevationAsync(location.Coord.X, location.Coord.Y, biomeKey); - var migratedObject = TryMigrateLegacyResources(location) ?? CreateLocationObjectForBiome(biomeDefinitions, biomeKey, location.Coord.X, location.Coord.Y); + var migratedObject = TryMigrateLegacyResources(location); + if (migratedObject is null) + migratedObject = CreateLocationObjectForBiome(biomeDefinitions, biomeKey, location.Coord.X, location.Coord.Y); var filter = Builders.Filter.And( Builders.Filter.Eq(l => l.Id, location.Id) ); diff --git a/microservices/MailApi/Controllers/MailController.cs b/microservices/MailApi/Controllers/MailController.cs new file mode 100644 index 0000000..d462771 --- /dev/null +++ b/microservices/MailApi/Controllers/MailController.cs @@ -0,0 +1,93 @@ +using MailApi.Models; +using MailApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace MailApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class MailController : ControllerBase +{ + private readonly MailStore _mail; + + public MailController(MailStore mail) + { + _mail = mail; + } + + [HttpGet("characters/{characterId}")] + [Authorize(Roles = "USER,SUPER")] + public async Task GetMailbox(string characterId) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var access = await _mail.ResolveCharacterAsync(characterId, userId, User.IsInRole("SUPER")); + if (!access.Exists) + return NotFound(); + if (!access.IsAuthorized) + return Forbid(); + + var inbox = await _mail.GetInboxAsync(characterId); + var sent = await _mail.GetSentAsync(characterId); + return Ok(new MailboxResponse + { + CharacterId = characterId, + Inbox = inbox.Select(MailMessageResponse.FromModel).ToList(), + Sent = sent.Select(MailMessageResponse.FromModel).ToList() + }); + } + + [HttpPost("characters/{characterId}/send")] + [Authorize(Roles = "USER,SUPER")] + public async Task Send(string characterId, [FromBody] SendMailRequest req) + { + if (string.IsNullOrWhiteSpace(req.RecipientCharacterName)) + return BadRequest("recipientCharacterName required"); + if (string.IsNullOrWhiteSpace(req.Subject)) + return BadRequest("subject required"); + if (string.IsNullOrWhiteSpace(req.Body)) + return BadRequest("body required"); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var access = await _mail.ResolveCharacterAsync(characterId, userId, User.IsInRole("SUPER")); + if (!access.Exists) + return NotFound(); + if (!access.IsAuthorized) + return Forbid(); + + var result = await _mail.SendAsync(characterId, req.RecipientCharacterName, req.Subject, req.Body); + return result.Status switch + { + MailStore.SendMailStatus.SenderNotFound => NotFound("Sender character not found"), + MailStore.SendMailStatus.RecipientNotFound => NotFound("Recipient character not found"), + MailStore.SendMailStatus.RecipientAmbiguous => Conflict("Recipient character name is ambiguous"), + MailStore.SendMailStatus.Invalid => BadRequest("Invalid recipient"), + _ => Ok(MailMessageResponse.FromModel(result.Message!)) + }; + } + + [HttpPost("characters/{characterId}/messages/{messageId}/read")] + [Authorize(Roles = "USER,SUPER")] + public async Task MarkRead(string characterId, string messageId) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var access = await _mail.ResolveCharacterAsync(characterId, userId, User.IsInRole("SUPER")); + if (!access.Exists) + return NotFound(); + if (!access.IsAuthorized) + return Forbid(); + + var message = await _mail.MarkReadAsync(characterId, messageId); + return message is null ? NotFound() : Ok(MailMessageResponse.FromModel(message)); + } +} diff --git a/microservices/MailApi/DOCUMENTS.md b/microservices/MailApi/DOCUMENTS.md new file mode 100644 index 0000000..fe7e24c --- /dev/null +++ b/microservices/MailApi/DOCUMENTS.md @@ -0,0 +1,5 @@ +# MailApi + +- `GET /api/mail/characters/{characterId}` returns inbox and sent mail. +- `POST /api/mail/characters/{characterId}/send` sends mail to another character by exact character name. +- `POST /api/mail/characters/{characterId}/messages/{messageId}/read` marks an inbox message as read. diff --git a/microservices/MailApi/Dockerfile b/microservices/MailApi/Dockerfile new file mode 100644 index 0000000..8dabfe4 --- /dev/null +++ b/microservices/MailApi/Dockerfile @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY . . +RUN dotnet publish -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 5004 +ENTRYPOINT ["dotnet", "MailApi.dll"] diff --git a/microservices/MailApi/MailApi.csproj b/microservices/MailApi/MailApi.csproj new file mode 100644 index 0000000..0cb0950 --- /dev/null +++ b/microservices/MailApi/MailApi.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + enable + enable + + + + + + + + + + diff --git a/microservices/MailApi/Models/MailMessage.cs b/microservices/MailApi/Models/MailMessage.cs new file mode 100644 index 0000000..4d8015b --- /dev/null +++ b/microservices/MailApi/Models/MailMessage.cs @@ -0,0 +1,35 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace MailApi.Models; + +public class MailMessage +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("senderCharacterId")] + public string SenderCharacterId { get; set; } = string.Empty; + + [BsonElement("senderCharacterName")] + public string SenderCharacterName { get; set; } = string.Empty; + + [BsonElement("recipientCharacterId")] + public string RecipientCharacterId { get; set; } = string.Empty; + + [BsonElement("recipientCharacterName")] + public string RecipientCharacterName { get; set; } = string.Empty; + + [BsonElement("subject")] + public string Subject { get; set; } = string.Empty; + + [BsonElement("body")] + public string Body { get; set; } = string.Empty; + + [BsonElement("createdUtc")] + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + + [BsonElement("readUtc")] + public DateTime? ReadUtc { get; set; } +} diff --git a/microservices/MailApi/Models/MailMessageResponse.cs b/microservices/MailApi/Models/MailMessageResponse.cs new file mode 100644 index 0000000..6c87a9d --- /dev/null +++ b/microservices/MailApi/Models/MailMessageResponse.cs @@ -0,0 +1,35 @@ +namespace MailApi.Models; + +public class MailMessageResponse +{ + public string Id { get; set; } = string.Empty; + + public string SenderCharacterId { get; set; } = string.Empty; + + public string SenderCharacterName { get; set; } = string.Empty; + + public string RecipientCharacterId { get; set; } = string.Empty; + + public string RecipientCharacterName { get; set; } = string.Empty; + + public string Subject { get; set; } = string.Empty; + + public string Body { get; set; } = string.Empty; + + public DateTime CreatedUtc { get; set; } + + public DateTime? ReadUtc { get; set; } + + public static MailMessageResponse FromModel(MailMessage message) => new() + { + Id = message.Id ?? string.Empty, + SenderCharacterId = message.SenderCharacterId, + SenderCharacterName = message.SenderCharacterName, + RecipientCharacterId = message.RecipientCharacterId, + RecipientCharacterName = message.RecipientCharacterName, + Subject = message.Subject, + Body = message.Body, + CreatedUtc = message.CreatedUtc, + ReadUtc = message.ReadUtc + }; +} diff --git a/microservices/MailApi/Models/MailboxResponse.cs b/microservices/MailApi/Models/MailboxResponse.cs new file mode 100644 index 0000000..9d89db8 --- /dev/null +++ b/microservices/MailApi/Models/MailboxResponse.cs @@ -0,0 +1,10 @@ +namespace MailApi.Models; + +public class MailboxResponse +{ + public string CharacterId { get; set; } = string.Empty; + + public List Inbox { get; set; } = []; + + public List Sent { get; set; } = []; +} diff --git a/microservices/MailApi/Models/SendMailRequest.cs b/microservices/MailApi/Models/SendMailRequest.cs new file mode 100644 index 0000000..617050f --- /dev/null +++ b/microservices/MailApi/Models/SendMailRequest.cs @@ -0,0 +1,10 @@ +namespace MailApi.Models; + +public class SendMailRequest +{ + public string RecipientCharacterName { get; set; } = string.Empty; + + public string Subject { get; set; } = string.Empty; + + public string Body { get; set; } = string.Empty; +} diff --git a/microservices/MailApi/Program.cs b/microservices/MailApi/Program.cs new file mode 100644 index 0000000..d2b6969 --- /dev/null +++ b/microservices/MailApi/Program.cs @@ -0,0 +1,105 @@ +using MailApi.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Text; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); +builder.Services.AddSingleton(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Mail API", Version = "v1" }); + c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "Paste your access token here (no 'Bearer ' prefix needed)." + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" } + }, + Array.Empty() + } + }); +}); + +var cfg = builder.Configuration; +var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing"); +var issuer = cfg["Jwt:Issuer"] ?? "promiscuity"; +var aud = cfg["Jwt:Audience"] ?? issuer; + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(o => + { + o.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = issuer, + ValidateAudience = true, + ValidAudience = aud, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30) + }; + }); + +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +app.UseExceptionHandler(errorApp => +{ + errorApp.Run(async context => + { + var feature = context.Features.Get(); + var exception = feature?.Error; + var logger = context.RequestServices.GetRequiredService().CreateLogger("GlobalException"); + var traceId = context.TraceIdentifier; + + if (exception is not null) + { + logger.LogError( + exception, + "Unhandled exception for {Method} {Path}. TraceId={TraceId}", + context.Request.Method, + context.Request.Path, + traceId + ); + } + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/problem+json"; + + await context.Response.WriteAsJsonAsync(new + { + type = "https://httpstatuses.com/500", + title = "Internal Server Error", + status = 500, + detail = exception?.Message ?? "An unexpected server error occurred.", + traceId + }); + }); +}); + +app.MapGet("/healthz", () => Results.Ok("ok")); +app.UseSwagger(); +app.UseSwaggerUI(o => +{ + o.SwaggerEndpoint("/swagger/v1/swagger.json", "Mail API v1"); + o.RoutePrefix = "swagger"; +}); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); diff --git a/microservices/MailApi/README.md b/microservices/MailApi/README.md new file mode 100644 index 0000000..cd8fd68 --- /dev/null +++ b/microservices/MailApi/README.md @@ -0,0 +1,3 @@ +# MailApi + +Stores character-to-character mail and exposes mailbox read and send endpoints. diff --git a/microservices/MailApi/Services/MailStore.cs b/microservices/MailApi/Services/MailStore.cs new file mode 100644 index 0000000..abb7fe4 --- /dev/null +++ b/microservices/MailApi/Services/MailStore.cs @@ -0,0 +1,137 @@ +using MailApi.Models; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +namespace MailApi.Services; + +public class MailStore +{ + private readonly IMongoCollection _messages; + private readonly IMongoCollection _characters; + + public MailStore(IConfiguration cfg) + { + var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var dbName = cfg["MongoDB:DatabaseName"] ?? "promiscuity"; + var client = new MongoClient(cs); + var db = client.GetDatabase(dbName); + _messages = db.GetCollection("MailMessages"); + _characters = db.GetCollection("Characters"); + + _messages.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(m => m.RecipientCharacterId).Descending(m => m.CreatedUtc))); + _messages.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(m => m.SenderCharacterId).Descending(m => m.CreatedUtc))); + } + + public async Task ResolveCharacterAsync(string characterId, string userId, bool allowAnyOwner) + { + var character = await _characters.Find(c => c.Id == characterId).FirstOrDefaultAsync(); + if (character is null) + return new CharacterAccessResult { Exists = false }; + + return new CharacterAccessResult + { + Exists = true, + IsAuthorized = allowAnyOwner || character.OwnerUserId == userId, + CharacterId = character.Id ?? string.Empty, + CharacterName = character.Name, + OwnerUserId = character.OwnerUserId + }; + } + + public async Task> GetInboxAsync(string characterId) => + await _messages.Find(m => m.RecipientCharacterId == characterId) + .SortByDescending(m => m.CreatedUtc) + .ToListAsync(); + + public async Task> GetSentAsync(string characterId) => + await _messages.Find(m => m.SenderCharacterId == characterId) + .SortByDescending(m => m.CreatedUtc) + .ToListAsync(); + + public async Task SendAsync(string senderCharacterId, string recipientCharacterName, string subject, string body) + { + var sender = await _characters.Find(c => c.Id == senderCharacterId).FirstOrDefaultAsync(); + if (sender is null) + return new SendMailResult { Status = SendMailStatus.SenderNotFound }; + + var normalizedRecipientName = recipientCharacterName.Trim(); + var recipients = await _characters.Find(c => c.Name == normalizedRecipientName).ToListAsync(); + if (recipients.Count == 0) + return new SendMailResult { Status = SendMailStatus.RecipientNotFound }; + if (recipients.Count > 1) + return new SendMailResult { Status = SendMailStatus.RecipientAmbiguous }; + + var recipient = recipients[0]; + if (recipient.Id == sender.Id) + return new SendMailResult { Status = SendMailStatus.Invalid }; + + var message = new MailMessage + { + SenderCharacterId = sender.Id ?? string.Empty, + SenderCharacterName = sender.Name, + RecipientCharacterId = recipient.Id ?? string.Empty, + RecipientCharacterName = recipient.Name, + Subject = subject.Trim(), + Body = body.Trim(), + CreatedUtc = DateTime.UtcNow + }; + + await _messages.InsertOneAsync(message); + return new SendMailResult { Status = SendMailStatus.Ok, Message = message }; + } + + public async Task MarkReadAsync(string characterId, string messageId) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(m => m.Id, messageId), + Builders.Filter.Eq(m => m.RecipientCharacterId, characterId) + ); + var update = Builders.Update.Set(m => m.ReadUtc, DateTime.UtcNow); + var options = new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }; + return await _messages.FindOneAndUpdateAsync(filter, update, options); + } + + public class CharacterAccessResult + { + public bool Exists { get; set; } + + public bool IsAuthorized { get; set; } + + public string CharacterId { get; set; } = string.Empty; + + public string CharacterName { get; set; } = string.Empty; + + public string OwnerUserId { get; set; } = string.Empty; + } + + public class SendMailResult + { + public SendMailStatus Status { get; set; } + + public MailMessage? Message { get; set; } + } + + public enum SendMailStatus + { + Ok, + SenderNotFound, + RecipientNotFound, + RecipientAmbiguous, + Invalid + } + + [BsonIgnoreExtraElements] + private class CharacterDocument + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public string OwnerUserId { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + } +} diff --git a/microservices/MailApi/appsettings.Development.json b/microservices/MailApi/appsettings.Development.json new file mode 100644 index 0000000..80ef7c9 --- /dev/null +++ b/microservices/MailApi/appsettings.Development.json @@ -0,0 +1,7 @@ +{ + "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5004" } } }, + "MongoDB": { "ConnectionString": "mongodb://127.0.0.1:27017", "DatabaseName": "promiscuity" }, + "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "Logging": { "LogLevel": { "Default": "Information" } }, + "AllowedHosts": "*" +} diff --git a/microservices/MailApi/appsettings.json b/microservices/MailApi/appsettings.json new file mode 100644 index 0000000..c207f90 --- /dev/null +++ b/microservices/MailApi/appsettings.json @@ -0,0 +1,7 @@ +{ + "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5004" } } }, + "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, + "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "Logging": { "LogLevel": { "Default": "Information" } }, + "AllowedHosts": "*" +} diff --git a/microservices/MailApi/k8s/deployment.yaml b/microservices/MailApi/k8s/deployment.yaml new file mode 100644 index 0000000..d859b87 --- /dev/null +++ b/microservices/MailApi/k8s/deployment.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: promiscuity-mail + labels: + app: promiscuity-mail +spec: + replicas: 2 + selector: + matchLabels: + app: promiscuity-mail + template: + metadata: + labels: + app: promiscuity-mail + spec: + containers: + - name: promiscuity-mail + image: promiscuity-mail:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5004 + readinessProbe: + httpGet: + path: /healthz + port: 5004 + initialDelaySeconds: 5 + periodSeconds: 10 diff --git a/microservices/MailApi/k8s/service.yaml b/microservices/MailApi/k8s/service.yaml new file mode 100644 index 0000000..49d02c9 --- /dev/null +++ b/microservices/MailApi/k8s/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: promiscuity-mail + labels: + app: promiscuity-mail +spec: + selector: + app: promiscuity-mail + type: NodePort + ports: + - name: http + port: 80 + targetPort: 5004 + nodePort: 30084 diff --git a/microservices/micro-services.sln b/microservices/micro-services.sln index 685a13a..ae20ffd 100644 --- a/microservices/micro-services.sln +++ b/microservices/micro-services.sln @@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocationsApi", "LocationsAp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryApi", "InventoryApi\InventoryApi.csproj", "{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MailApi", "MailApi\MailApi.csproj", "{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,6 +34,10 @@ Global {72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|Any CPU.Build.0 = Debug|Any CPU {72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|Any CPU.ActiveCfg = Release|Any CPU {72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|Any CPU.Build.0 = Release|Any CPU + {2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE